Merge branch 'master' into dont-leak-internal-errors

This commit is contained in:
Adam Rutkowski 2024-01-03 13:43:38 +01:00
commit e9de698242
27 changed files with 593 additions and 312 deletions

View File

@ -31,6 +31,7 @@ All notable changes to this project will be documented in this file.
- Replace `CLICKHOUSE_MAX_BUFFER_SIZE` with `CLICKHOUSE_MAX_BUFFER_SIZE_BYTES`
### Fixed
- Stop returning custom events in goal breakdown with a pageview goal filter and vice versa
- Only return `(none)` values in custom property breakdown for the first page (pagination) of results
- Fixed weekly/monthly e-mail report [rendering issues](https://github.com/plausible/analytics/issues/284)
- Fix [broken interval selection](https://github.com/plausible/analytics/issues/2982) in the all time view plausible/analytics#3110

View File

@ -16,10 +16,12 @@ if (container) {
domain: container.dataset.domain,
offset: container.dataset.offset,
hasGoals: container.dataset.hasGoals === 'true',
conversionsEnabled: container.dataset.conversionsEnabled === 'true',
funnelsEnabled: container.dataset.funnelsEnabled === 'true',
propsEnabled: container.dataset.propsEnabled === 'true',
hasProps: container.dataset.hasProps === 'true',
funnelsAvailable: container.dataset.funnelsAvailable === 'true',
propsAvailable: container.dataset.propsAvailable === 'true',
conversionsOptedOut: container.dataset.conversionsOptedOut === 'true',
funnelsOptedOut: container.dataset.funnelsOptedOut === 'true',
propsOptedOut: container.dataset.propsOptedOut === 'true',
funnels: JSON.parse(container.dataset.funnels),
statsBegin: container.dataset.statsBegin,
nativeStatsBegin: container.dataset.nativeStatsBegin,

View File

@ -2,7 +2,7 @@ import React from "react"
import { sectionTitles } from "../stats/behaviours"
import * as api from '../api'
export function FeatureSetupNotice({ site, feature, shortFeatureName, title, info, settingsLink, onHideAction }) {
export function FeatureSetupNotice({ site, feature, title, info, callToAction, onHideAction }) {
const sectionTitle = sectionTitles[feature]
const requestHideSection = () => {
@ -14,10 +14,10 @@ export function FeatureSetupNotice({ site, feature, shortFeatureName, title, inf
}
}
function setupButton() {
function renderCallToAction() {
return (
<a href={settingsLink} className="ml-2 sm:ml-4 button px-2 sm:px-4">
<p className="flex flex-col justify-center text-xs sm:text-sm">Set up {shortFeatureName}</p>
<a href={callToAction.link} className="ml-2 sm:ml-4 button px-2 sm:px-4">
<p className="flex flex-col justify-center text-xs sm:text-sm">{callToAction.action}</p>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="ml-2 w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
</svg>
@ -25,7 +25,7 @@ export function FeatureSetupNotice({ site, feature, shortFeatureName, title, inf
)
}
function hideButton() {
function renderHideButton() {
return (
<button
onClick={requestHideSection}
@ -47,8 +47,8 @@ export function FeatureSetupNotice({ site, feature, shortFeatureName, title, inf
</div>
<div className="text-xs sm:text-sm flex my-6 justify-center">
{hideButton()}
{setupButton()}
{renderHideButton()}
{renderCallToAction()}
</div>
</div>
</div>

View File

@ -43,7 +43,7 @@ export default function Behaviours(props) {
const [mode, setMode] = useState(defaultMode())
const [funnelNames, _setFunnelNames] = useState(site.funnels.map(({ name }) => name))
const [selectedFunnel, setSelectedFunnel] = useState(storage.getItem(funnelKey))
const [selectedFunnel, setSelectedFunnel] = useState(defaultSelectedFunnel())
const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] = useState(false)
@ -83,8 +83,22 @@ export default function Behaviours(props) {
}
}
function defaultSelectedFunnel() {
const stored = storage.getItem(funnelKey)
const storedExists = stored && site.funnels.some((f) => f.name === stored)
if (storedExists) {
return stored
} else if (site.funnels.length > 0) {
const firstAvailable = site.funnels[0].name
storage.setItem(funnelKey, firstAvailable)
return firstAvailable
}
}
function hasFunnels() {
return site.funnels.length > 0
return site.funnels.length > 0 && site.funnelsAvailable
}
function tabFunnelPicker() {
@ -164,10 +178,12 @@ export default function Behaviours(props) {
<FeatureSetupNotice
site={site}
feature={CONVERSIONS}
shortFeatureName={'goals'}
title={'Measure how often visitors complete specific actions'}
info={'Goals allow you to track registrations, button clicks, form completions, external link clicks, file downloads, 404 error pages and more.'}
settingsLink={`/${encodeURIComponent(site.domain)}/settings/goals`}
callToAction={{
action: "Set up goals",
link: `/${encodeURIComponent(site.domain)}/settings/goals`
}}
onHideAction={onHideAction(CONVERSIONS)}
/>
)
@ -179,18 +195,25 @@ export default function Behaviours(props) {
if (Funnel === null) {
return featureUnavailable()
}
else if (Funnel && selectedFunnel) {
else if (Funnel && selectedFunnel && site.funnelsAvailable) {
return <Funnel site={site} query={query} funnelName={selectedFunnel} />
}
else if (Funnel && adminAccess) {
let callToAction
if (site.funnelsAvailable) {
callToAction = {action: 'Set up funnels', link: `/${encodeURIComponent(site.domain)}/settings/funnels`}
} else {
callToAction = {action: 'Upgrade', link: '/billing/choose-plan'}
}
return (
<FeatureSetupNotice
site={site}
feature={FUNNELS}
shortFeatureName={'funnels'}
title={'Follow the visitor journey from entry to conversion'}
info={'Funnels allow you to analyze the user flow through your website, uncover possible issues, optimize your site and increase the conversion rate.'}
settingsLink={`/${encodeURIComponent(site.domain)}/settings/funnels`}
callToAction={callToAction}
onHideAction={onHideAction(FUNNELS)}
/>
)
@ -199,17 +222,24 @@ export default function Behaviours(props) {
}
function renderProps() {
if (site.hasProps) {
if (site.hasProps && site.propsAvailable) {
return <Properties site={site} query={query} />
} else if (adminAccess) {
let callToAction
if (site.propsAvailable) {
callToAction = {action: 'Set up props', link: `/${encodeURIComponent(site.domain)}/settings/properties`}
} else {
callToAction = {action: 'Upgrade', link: '/billing/choose-plan'}
}
return (
<FeatureSetupNotice
site={site}
feature={PROPS}
shortFeatureName={'props'}
title={'No custom properties found'}
title={'Send custom data to create your own metrics'}
info={'You can attach custom properties when sending a pageview or event. This allows you to create custom metrics and analyze stats we don\'t track automatically.'}
settingsLink={`/${encodeURIComponent(site.domain)}/settings/properties`}
callToAction={callToAction}
onHideAction={onHideAction(PROPS)}
/>
)
@ -261,15 +291,20 @@ export default function Behaviours(props) {
function getEnabledModes() {
let enabledModes = []
if (site.conversionsEnabled) {
enabledModes.push(CONVERSIONS)
}
if (site.propsEnabled) {
enabledModes.push(PROPS)
}
if (site.funnelsEnabled && !isRealtime()) {
enabledModes.push(FUNNELS)
for (const feature of Object.keys(sectionTitles)) {
const isOptedOut = site[feature + 'OptedOut']
const isAvailable = site[feature + 'Available'] !== false
// If the feature is not supported by the site owner's subscription,
// it only makes sense to display the feature tab to the owner itself
// as only they can upgrade to make the feature available.
const callToActionIsMissing = !isAvailable && currentUserRole !== 'owner'
if (!isOptedOut && !callToActionIsMissing) {
enabledModes.push(feature)
}
}
return enabledModes
}

View File

@ -24,7 +24,7 @@ class Modal extends React.Component {
componentDidMount() {
document.body.style.overflow = 'hidden';
document.body.style.height = '100vh';
document.addEventListener("click", this.handleClickOutside);
document.addEventListener("mousedown", this.handleClickOutside);
document.addEventListener("keyup", this.handleKeyup);
window.addEventListener('resize', this.handleResize, false);
this.handleResize();
@ -33,7 +33,7 @@ class Modal extends React.Component {
componentWillUnmount() {
document.body.style.overflow = null;
document.body.style.height = null;
document.removeEventListener("click", this.handleClickOutside);
document.removeEventListener("mousedown", this.handleClickOutside);
document.removeEventListener("keyup", this.handleKeyup);
window.removeEventListener('resize', this.handleResize, false);
}

View File

@ -56,6 +56,13 @@ defmodule Plausible.Billing.Feature do
"""
@callback enabled?(Plausible.Site.t()) :: boolean()
@doc """
Returns whether the site explicitly opted out of the feature. This function
is different from enabled/1, because enabled/1 returns false when the site
owner does not have access to the feature.
"""
@callback opted_out?(Plausible.Site.t()) :: boolean()
@doc """
Checks whether the site owner or the user plan includes the given feature.
"""
@ -101,12 +108,12 @@ defmodule Plausible.Billing.Feature do
@impl true
def enabled?(%Plausible.Site{} = site) do
site = Plausible.Repo.preload(site, :owner)
check_availability(site.owner) == :ok && !opted_out?(site)
end
cond do
check_availability(site.owner) !== :ok -> false
is_nil(toggle_field()) -> true
true -> Map.fetch!(site, toggle_field())
end
@impl true
def opted_out?(%Plausible.Site{} = site) do
if is_nil(toggle_field()), do: false, else: !Map.fetch!(site, toggle_field())
end
@impl true
@ -120,18 +127,23 @@ defmodule Plausible.Billing.Feature do
@impl true
def toggle(%Plausible.Site{} = site, opts \\ []) do
with key when not is_nil(key) <- toggle_field(),
site <- Plausible.Repo.preload(site, :owner),
:ok <- check_availability(site.owner) do
override = Keyword.get(opts, :override)
toggle = if is_boolean(override), do: override, else: !Map.fetch!(site, toggle_field())
if toggle_field(), do: do_toggle(site, opts), else: :ok
end
site
|> Ecto.Changeset.change(%{toggle_field() => toggle})
|> Plausible.Repo.update()
else
nil = _feature_not_togglable -> :ok
{:error, :upgrade_required} -> {:error, :upgrade_required}
defp do_toggle(%Plausible.Site{} = site, opts) do
site = Plausible.Repo.preload(site, :owner)
override = Keyword.get(opts, :override)
toggle = if is_boolean(override), do: override, else: !Map.fetch!(site, toggle_field())
availability = if toggle, do: check_availability(site.owner), else: :ok
case availability do
:ok ->
site
|> Ecto.Changeset.change(%{toggle_field() => toggle})
|> Plausible.Repo.update()
error ->
error
end
end

View File

@ -68,6 +68,7 @@ defmodule Plausible.Site do
|> cast(attrs, [:domain, :timezone])
|> clean_domain()
|> validate_required([:domain, :timezone])
|> validate_timezone()
|> validate_domain_format()
|> validate_domain_reserved_characters()
|> unique_constraint(:domain,
@ -265,4 +266,14 @@ defmodule Plausible.Site do
changeset
end
end
defp validate_timezone(changeset) do
tz = get_field(changeset, :timezone)
if Timex.is_valid_timezone?(tz) do
changeset
else
add_error(changeset, :timezone, "is invalid")
end
end
end

View File

@ -60,18 +60,24 @@ defmodule Plausible.Stats.Base do
q =
case query.filters["event:goal"] do
{:is, {:page, path}} ->
from(e in q, where: e.pathname == ^path)
from(e in q, where: e.pathname == ^path and e.name == "pageview")
{:matches, {:page, expr}} ->
regex = page_regex(expr)
from(e in q, where: fragment("match(?, ?)", e.pathname, ^regex))
from(e in q,
where: fragment("match(?, ?)", e.pathname, ^regex) and e.name == "pageview"
)
{:is, {:event, event}} ->
from(e in q, where: e.name == ^event)
{:member, clauses} ->
{events, pages} = split_goals(clauses)
from(e in q, where: e.pathname in ^pages or e.name in ^events)
from(e in q,
where: (e.pathname in ^pages and e.name == "pageview") or e.name in ^events
)
{:matches_member, clauses} ->
{events, pages} = split_goals(clauses, &page_regex/1)
@ -85,7 +91,10 @@ defmodule Plausible.Stats.Base do
page_clause =
if Enum.any?(pages) do
dynamic([x], fragment("multiMatchAny(?, ?)", x.pathname, ^pages))
dynamic(
[x],
fragment("multiMatchAny(?, ?)", x.pathname, ^pages) and x.name == "pageview"
)
else
dynamic([x], false)
end
@ -94,31 +103,6 @@ defmodule Plausible.Stats.Base do
from(e in q, where: ^where_clause)
{:not_matches_member, clauses} ->
{events, pages} = split_goals(clauses, &page_regex/1)
event_clause =
if Enum.any?(events) do
dynamic([x], fragment("multiMatchAny(?, ?)", x.name, ^events))
else
dynamic([x], false)
end
page_clause =
if Enum.any?(pages) do
dynamic([x], fragment("multiMatchAny(?, ?)", x.pathname, ^pages))
else
dynamic([x], false)
end
where_clause = dynamic([], not (^event_clause or ^page_clause))
from(e in q, where: ^where_clause)
{:not_member, clauses} ->
{events, pages} = split_goals(clauses)
from(e in q, where: e.pathname not in ^pages and e.name not in ^events)
nil ->
q
end

View File

@ -57,7 +57,7 @@ defmodule Plausible.Stats.Breakdown do
"notEmpty(multiMatchAllIndices(?, ?) as indices)",
e.pathname,
^page_regexes
),
) and e.name == "pageview",
group_by: fragment("index"),
select: %{
index: fragment("arrayJoin(indices) as index"),

View File

@ -368,6 +368,7 @@ defmodule Plausible.Stats.Imported do
defp select_imported_metrics(q, [:pageviews | rest]) do
q
|> where([i], i.pageviews > 0)
|> select_merge([i], %{pageviews: sum(i.pageviews)})
|> select_imported_metrics(rest)
end

View File

@ -1214,21 +1214,30 @@ defmodule PlausibleWeb.Api.StatsController do
prop_names = Plausible.Stats.CustomProps.fetch_prop_names(site, query)
values =
prop_names
|> Enum.map(fn prop_key ->
breakdown_custom_prop_values(site, Map.put(params, "prop_key", prop_key))
|> Enum.map(&Map.put(&1, :property, prop_key))
|> transform_keys(%{:name => :value})
end)
|> Enum.concat()
prop_names =
if Plausible.Billing.Feature.Props.enabled?(site) do
prop_names
else
prop_names |> Enum.filter(&(&1 in Plausible.Props.internal_keys()))
end
percent_or_cr =
if query.filters["event:goal"],
do: :conversion_rate,
else: :percentage
if not Enum.empty?(prop_names) do
values =
prop_names
|> Enum.map(fn prop_key ->
breakdown_custom_prop_values(site, Map.put(params, "prop_key", prop_key))
|> Enum.map(&Map.put(&1, :property, prop_key))
|> transform_keys(%{:name => :value})
end)
|> Enum.concat()
to_csv(values, [:property, :value, :visitors, :events, percent_or_cr])
percent_or_cr =
if query.filters["event:goal"],
do: :conversion_rate,
else: :percentage
to_csv(values, [:property, :value, :visitors, :events, percent_or_cr])
end
end
defp breakdown_custom_prop_values(site, %{"prop_key" => prop_key} = params) do

View File

@ -159,18 +159,10 @@ defmodule PlausibleWeb.StatsController do
~c"operating_systems.csv" => fn -> Api.StatsController.operating_systems(conn, params) end,
~c"devices.csv" => fn -> Api.StatsController.screen_sizes(conn, params) end,
~c"conversions.csv" => fn -> Api.StatsController.conversions(conn, params) end,
~c"referrers.csv" => fn -> Api.StatsController.referrers(conn, params) end
~c"referrers.csv" => fn -> Api.StatsController.referrers(conn, params) end,
~c"custom_props.csv" => fn -> Api.StatsController.all_custom_prop_values(conn, params) end
}
csvs =
if Plausible.Billing.Feature.Props.enabled?(site) do
Map.put(csvs, ~c"custom_props.csv", fn ->
Api.StatsController.all_custom_prop_values(conn, params)
end)
else
csvs
end
csv_values =
Map.values(csvs)
|> Plausible.ClickhouseRepo.parallel_tasks()
@ -178,6 +170,7 @@ defmodule PlausibleWeb.StatsController do
csvs =
Map.keys(csvs)
|> Enum.zip(csv_values)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
csvs = [{~c"visitors.csv", visitors} | csvs]
@ -315,6 +308,8 @@ defmodule PlausibleWeb.StatsController do
defp render_shared_link(conn, shared_link) do
cond do
!shared_link.site.locked ->
shared_link = Plausible.Repo.preload(shared_link, site: :owner)
conn
|> put_resp_header("x-robots-tag", "noindex, nofollow")
|> delete_resp_header("x-frame-options")

View File

@ -353,20 +353,26 @@ defmodule PlausibleWeb.Email do
})
end
def approaching_accept_traffic_until(user) do
def approaching_accept_traffic_until(notification) do
base_email()
|> to(user.email)
|> to(notification.email)
|> tag("drop-traffic-warning-first")
|> subject("We'll stop counting your stats")
|> render("approaching_accept_traffic_until.html", time: "next week")
|> render("approaching_accept_traffic_until.html",
time: "next week",
user: %{email: notification.email, name: notification.name}
)
end
def approaching_accept_traffic_until_tomorrow(user) do
def approaching_accept_traffic_until_tomorrow(notification) do
base_email()
|> to(user.email)
|> to(notification.email)
|> tag("drop-traffic-warning-final")
|> subject("A reminder that we'll stop counting your stats tomorrow")
|> render("approaching_accept_traffic_until.html", time: "tomorrow")
|> render("approaching_accept_traffic_until.html",
time: "tomorrow",
user: %{email: notification.email, name: notification.name}
)
end
@doc """

View File

@ -1,8 +1,7 @@
You used to have an active account with Plausible Analytics, a simple, lightweight, open source and privacy-first Google Analytics alternative.
<br /><br />
We've noticed that you're still sending us stats so we're writing to inform you that we'll stop accepting stats from your sites <%= @time %>. We're an independent, bootstrapped service and we don't sell your data, so this will reduce our server costs and help keep us sustainable.
If you'd like to continue counting your site stats in a privacy-friendly way, please
<br /><br /> If you'd like to continue counting your site stats in a privacy-friendly way, please
<a href={plausible_url()}>login to your Plausible account</a> and start a subscription.
<br /><br />
Do you have any questions or need help with anything? Just reply to this email and we'll gladly help.

View File

@ -1,49 +0,0 @@
<div class="w-full max-w-lg mx-auto mt-8">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:px-8 sm:py-6">
<div class="mx-auto flex items-center justify-center rounded-full bg-green-100 h-12 w-12">
<svg class="w-6 h-6 text-green-600" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path></svg>
</div>
<h3 class="mt-6 text-center text-2xl leading-6 font-medium text-gray-900 dark:text-gray-200">
Dashboard locked
</h3>
<%= case @conn.assigns[:current_user_role] do %>
<% :owner -> %>
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
<p>
This dashboard is locked because you don't have a valid subscription. We're still counting the stats but your access to the dashboard is restricted. Please subscribe to the appropriate tier with the link below to access the stats again.
</p>
</div>
<div class="mt-6 w-full text-center">
<%= link("Manage my subscription", to: "/settings", class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm") %>
</div>
<% role when role in [:admin, :viewer] -> %>
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
<p>
This dashboard is currently locked and cannot be accessed. The site owner <b><%= @site.owner.email %></b> must upgrade their subscription plan in order to
unlock the stats.
</p>
<div class="mt-6 text-sm text-gray-500">
<p>Want to pay for this site with the account you're logged in with?</p>
<p class="mt-1">Contact <%= @site.owner.email %> and ask them to <%= link("transfer the ownership", class: "text-indigo-500", to: "https://plausible.io/docs/transfer-ownership", rel: "noreferrer") %> of the site over to you</p>
</div>
</div>
<div class="mt-6 w-full text-center">
<%= link("Back to my sites", to: "/sites", class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm") %>
</div>
<% _ -> %>
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
<p>
This dashboard is currently locked and cannot be accessed. You can check back later or contact the site owner to unlock it.
</p>
</div>
<%= if @conn.assigns[:current_user] do %>
<div class="mt-6 w-full text-center">
<%= link("Back to my sites", to: "/sites", class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm") %>
</div>
<% end %>
<% end %>
</div>
</div>
</div>

View File

@ -0,0 +1,80 @@
<div class="w-full max-w-lg mx-auto mt-8">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:px-8 sm:py-6">
<div class="mx-auto flex items-center justify-center rounded-full bg-green-100 h-12 w-12">
<svg
class="w-6 h-6 text-green-600"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
clip-rule="evenodd"
>
</path>
</svg>
</div>
<h3 class="mt-6 text-center text-2xl leading-6 font-medium text-gray-900 dark:text-gray-200">
Dashboard locked
</h3>
<%= case @conn.assigns[:current_user_role] do %>
<% :owner -> %>
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
<p>
This dashboard is locked because you don't have a valid subscription.
Please subscribe to the appropriate tier with the link below to access the stats again.
</p>
<p class="mt-6 text-sm text-gray-500">
You can check the
<.styled_link href={Routes.site_path(@conn, :settings_general, @site.domain)}>
site settings
</.styled_link>
and we're still counting the stats but your access to the dashboard is restricted.
</p>
</div>
<div class="mt-6 w-full text-center">
<.button_link href={Routes.auth_path(@conn, :user_settings)}>
Manage my subscription
</.button_link>
</div>
<% role when role in [:admin, :viewer] -> %>
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
<p>
This dashboard is currently locked and cannot be accessed. The site owner
<b><%= @site.owner.email %></b>
must upgrade their subscription plan in order to
unlock the stats.
</p>
<div class="mt-6 text-sm text-gray-500">
<p>Want to pay for this site with the account you're logged in with?</p>
<p class="mt-1">
Contact <%= @site.owner.email %> and ask them to
<.styled_link href="https://plausible.io/docs/transfer-ownership" new_tab={true}>
transfer the ownership
</.styled_link>
of the site over to you.
</p>
</div>
</div>
<div class="mt-6 w-full text-center">
<.button_link href={Routes.site_path(@conn, :index)}>Back to my sites</.button_link>
</div>
<% _ -> %>
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
<p>
This dashboard is currently locked and cannot be accessed.
You can check back later or contact the site owner to unlock the stats.
</p>
</div>
<%= if @conn.assigns[:current_user] do %>
<div class="mt-6 w-full text-center">
<.button_link href={Routes.site_path(@conn, :index)}>Back to my sites</.button_link>
</div>
<% end %>
<% end %>
</div>
</div>
</div>

View File

@ -18,9 +18,11 @@
data-domain="<%= @site.domain %>"
data-offset="<%= Plausible.Site.tz_offset(@site) %>"
data-has-goals="<%= @has_goals %>"
data-conversions-enabled="<%= Plausible.Billing.Feature.Goals.enabled?(@site) %>"
data-funnels-enabled="<%= Plausible.Billing.Feature.Funnels.enabled?(@site) %>"
data-props-enabled="<%= Plausible.Billing.Feature.Props.enabled?(@site) %>"
data-conversions-opted-out="<%= Plausible.Billing.Feature.Goals.opted_out?(@site) %>"
data-funnels-opted-out="<%= Plausible.Billing.Feature.Funnels.opted_out?(@site) %>"
data-props-opted-out="<%= Plausible.Billing.Feature.Props.opted_out?(@site) %>"
data-funnels-available="<%= Plausible.Billing.Feature.Funnels.check_availability(@site.owner) == :ok %>"
data-props-available="<%= Plausible.Billing.Feature.Props.check_availability(@site.owner) == :ok %>"
data-funnels="<%= Jason.encode!(@funnels) %>"
data-has-props="<%= @has_props %>"
data-logged-in="<%= !!@conn.assigns[:current_user] %>"

View File

@ -40,7 +40,8 @@ defmodule Plausible.Workers.AcceptTrafficUntil do
id: u.id,
email: u.email,
deadline: u.accept_traffic_until,
site_ids: fragment("array_agg(?.site_id)", sm)
site_ids: fragment("array_agg(?.site_id)", sm),
name: u.name
},
group_by: u.id
)
@ -68,11 +69,11 @@ defmodule Plausible.Workers.AcceptTrafficUntil do
end
defp has_stats?(site_ids, today) do
yesterday = Date.add(today, -1)
ago_2d = Date.add(today, -2)
ClickhouseRepo.exists?(
from e in "events_v2",
where: fragment("toDate(?) >= ?", e.timestamp, ^yesterday),
where: fragment("toDate(?) >= ?", e.timestamp, ^ago_2d),
where: e.site_id in ^site_ids
)
end

View File

@ -0,0 +1,81 @@
defmodule Plausible.Repo.Migrations.BackfillAcceptTrafficUntil do
use Ecto.Migration
def change do
# trials that are about to expire get extra 14 days
# regardless of the effective end date, this still leaves a room for both notifications
execute """
UPDATE users
SET accept_traffic_until = trial_expiry_date + 14
WHERE
trial_expiry_date IS NOT NULL
AND
trial_expiry_date >= CURRENT_DATE
"""
# free plans
execute """
UPDATE users AS u
SET accept_traffic_until = '2135-01-01'
WHERE
EXISTS (
SELECT 1
FROM subscriptions s
WHERE
user_id = u.id
AND
paddle_plan_id = 'free_10k'
)
"""
# abandoned accounts (trial ended and no valid subscriptions) still get a random
# phase-out period so that both notifications can be delivered
execute """
UPDATE users
SET accept_traffic_until = CURRENT_DATE + TRUNC(RANDOM() * (20 - 8 + 1) + 8)::int
WHERE
NOT EXISTS (
SELECT 1
FROM subscriptions
WHERE
subscriptions.user_id = users.id
)
AND
trial_expiry_date IS NOT NULL
AND
trial_expiry_date < CURRENT_DATE
"""
# all the non-free subscriptions
execute """
UPDATE users u1
SET accept_traffic_until = s.next_bill_date + 30
FROM users u2
INNER JOIN LATERAL (
SELECT * FROM subscriptions sub WHERE u2.id = sub.user_id ORDER BY sub.inserted_at DESC LIMIT 1
) s ON (true)
WHERE
u1.id = u2.id
AND
s.user_id = u1.id
AND
s.paddle_plan_id != 'free_10k'
"""
# subscription for which current period needs payment)
execute """
UPDATE users u1
SET accept_traffic_until = CURRENT_DATE + TRUNC(RANDOM() * (20 - 8 + 1) + 8)::int
FROM users u2
INNER JOIN LATERAL (
SELECT * FROM subscriptions sub WHERE u2.id = sub.user_id ORDER BY sub.inserted_at DESC LIMIT 1
) s ON (true)
WHERE
s.user_id = u1.id
AND
u1.id = u2.id
AND
s.next_bill_date < CURRENT_DATE
"""
end
end

View File

@ -112,10 +112,12 @@ defmodule Plausible.Billing.FeatureTest do
{:ok, site} = unquote(mod).toggle(site)
assert Map.get(site, unquote(property))
assert unquote(mod).enabled?(site)
refute unquote(mod).opted_out?(site)
{:ok, site} = unquote(mod).toggle(site)
refute Map.get(site, unquote(property))
refute unquote(mod).enabled?(site)
assert unquote(mod).opted_out?(site)
end
test "#{mod}.toggle/2 accepts an override option" do
@ -126,12 +128,19 @@ defmodule Plausible.Billing.FeatureTest do
refute unquote(mod).enabled?(site)
end
test "#{mod}.toggle/2 errors when site owner does not have access to the feature" do
test "#{mod}.toggle/2 errors when enabling a feature the site owner does not have access to the feature" do
user = insert(:user, subscription: build(:growth_subscription))
site = insert(:site, [{:members, [user]}, {unquote(property), false}])
{:error, :upgrade_required} = unquote(mod).toggle(site)
refute unquote(mod).enabled?(site)
end
test "#{mod}.toggle/2 does not error when disabling a feature the site owner does not have access to" do
user = insert(:user, subscription: build(:growth_subscription))
site = insert(:site, [{:members, [user]}, {unquote(property), true}])
{:ok, site} = unquote(mod).toggle(site)
assert unquote(mod).opted_out?(site)
end
end
test "Plausible.Billing.Feature.Goals.toggle/2 toggles conversions_enabled on and off" do
@ -140,10 +149,12 @@ defmodule Plausible.Billing.FeatureTest do
{:ok, site} = Plausible.Billing.Feature.Goals.toggle(site)
assert Map.get(site, :conversions_enabled)
assert Plausible.Billing.Feature.Goals.enabled?(site)
refute Plausible.Billing.Feature.Goals.opted_out?(site)
{:ok, site} = Plausible.Billing.Feature.Goals.toggle(site)
refute Map.get(site, :conversions_enabled)
refute Plausible.Billing.Feature.Goals.enabled?(site)
assert Plausible.Billing.Feature.Goals.opted_out?(site)
end
for mod <- [Plausible.Billing.Feature.Funnels, Plausible.Billing.Feature.Props] do
@ -152,5 +163,11 @@ defmodule Plausible.Billing.FeatureTest do
site = insert(:site, [{:members, [user]}, {unquote(mod).toggle_field(), true}])
refute unquote(mod).enabled?(site)
end
test "#{mod}.opted_out?/1 returns false when feature toggle is enabled even when user does not have access to the feature" do
user = insert(:user, subscription: build(:growth_subscription))
site = insert(:site, [{:members, [user]}, {unquote(mod).toggle_field(), true}])
refute unquote(mod).opted_out?(site)
end
end
end

View File

@ -12,6 +12,15 @@ defmodule Plausible.SitesTest do
assert {:ok, %{site: %{domain: "example.com", timezone: "Europe/London"}}} =
Sites.create(user, params)
end
test "fails on invalid timezone" do
user = insert(:user)
params = %{"domain" => "example.com", "timezone" => "blah"}
assert {:error, :site, %{errors: [timezone: {"is invalid", []}]}, %{}} =
Sites.create(user, params)
end
end
describe "is_member?" do

View File

@ -26,6 +26,18 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
}
end
test "timezone is validated", %{conn: conn} do
conn =
post(conn, "/api/v1/sites", %{
"domain" => "some-site.domain",
"timezone" => "d"
})
assert json_response(conn, 400) == %{
"error" => "timezone: is invalid"
}
end
test "timezone defaults to Etc/UTC", %{conn: conn} do
conn =
post(conn, "/api/v1/sites", %{

View File

@ -523,6 +523,53 @@ defmodule PlausibleWeb.Api.ExternalStatsController.BreakdownTest do
}
end
test "pageviews breakdown by event:page - imported data having pageviews=0 and visitors=n should be bypassed",
%{conn: conn, site: site} do
site =
site
|> Plausible.Site.start_import(~D[2005-01-01], Timex.today(), "Google Analytics", "ok")
|> Plausible.Repo.update!()
populate_stats(site, [
build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:00:00]),
build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:25:00]),
build(:pageview,
pathname: "/plausible.io",
timestamp: ~N[2021-01-01 00:00:00]
),
build(:imported_pages,
page: "/skip-me",
date: ~D[2021-01-01],
visitors: 1,
pageviews: 0
),
build(:imported_pages,
page: "/include-me",
date: ~D[2021-01-01],
visitors: 1,
pageviews: 1
)
])
conn =
get(conn, "/api/v1/stats/breakdown", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"property" => "event:page",
"with_imported" => "true",
"metrics" => "pageviews"
})
assert json_response(conn, 200) == %{
"results" => [
%{"page" => "/", "pageviews" => 2},
%{"page" => "/plausible.io", "pageviews" => 1},
%{"page" => "/include-me", "pageviews" => 1}
]
}
end
test "breakdown by event:page", %{conn: conn, site: site} do
populate_stats(site, [
build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:00:00]),

View File

@ -265,16 +265,17 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
conn: conn,
site: site
} do
populate_stats(site, [
build(:event, name: "Signup"),
build(:event, name: "Signup"),
[
build(:event,
name: "Payment",
pathname: "/checkout",
revenue_reporting_amount: Decimal.new("10.00"),
revenue_reporting_currency: "EUR"
)
])
]
|> Enum.concat(build_list(2, :event, name: "Signup"))
|> Enum.concat(build_list(3, :pageview, pathname: "/checkout"))
|> then(fn events -> populate_stats(site, events) end)
insert(:goal, %{site: site, event_name: "Signup"})
insert(:goal, %{site: site, page_path: "/checkout"})
@ -286,7 +287,7 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
assert [
%{
"average_revenue" => %{"long" => "€10.00", "short" => "€10.0"},
"conversion_rate" => 33.3,
"conversion_rate" => 16.7,
"name" => "Payment",
"events" => 1,
"total_revenue" => %{"long" => "€10.00", "short" => "€10.0"},
@ -294,7 +295,7 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
},
%{
"average_revenue" => nil,
"conversion_rate" => 66.7,
"conversion_rate" => 33.3,
"name" => "Signup",
"events" => 2,
"total_revenue" => nil,
@ -302,11 +303,11 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
},
%{
"average_revenue" => nil,
"conversion_rate" => 33.3,
"conversion_rate" => 50.0,
"name" => "Visit /checkout",
"events" => 1,
"events" => 3,
"total_revenue" => nil,
"visitors" => 1
"visitors" => 3
}
] == Enum.sort_by(response, & &1["name"])
end
@ -337,35 +338,94 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
describe "GET /api/stats/:domain/conversions - with goal filter" do
setup [:create_user, :log_in, :create_new_site]
test "returns only the conversion that is filtered for", %{conn: conn, site: site} do
test "does not consider custom event pathname as a pageview goal completion", %{
conn: conn,
site: site
} do
populate_stats(site, [
build(:pageview, pathname: "/"),
build(:pageview, pathname: "/"),
build(:pageview, pathname: "/register"),
build(:pageview, pathname: "/register"),
build(:event, name: "Signup", "meta.key": ["variant"], "meta.value": ["A"]),
build(:event, name: "Signup", "meta.key": ["variant"], "meta.value": ["B"])
build(:event, pathname: "/register", name: "Signup")
])
insert(:goal, %{site: site, page_path: "/register"})
insert(:goal, %{site: site, event_name: "Signup"})
filters = Jason.encode!(%{goal: "Signup"})
get_with_filter = fn filters ->
path = "/api/stats/#{site.domain}/conversions"
query = "?period=day&filters=#{Jason.encode!(filters)}"
conn =
get(
conn,
"/api/stats/#{site.domain}/conversions?period=day&filters=#{filters}"
)
get(conn, path <> query)
|> json_response(200)
end
assert json_response(conn, 200) == [
%{
"name" => "Signup",
"visitors" => 2,
"events" => 2,
"conversion_rate" => 33.3
}
]
expected = [
%{
"name" => "Signup",
"visitors" => 1,
"events" => 1,
"conversion_rate" => 33.3
}
]
# {:is, {:event, event}} filter type
assert get_with_filter.(%{goal: "Signup"}) == expected
# {:member, clauses} filter type
assert get_with_filter.(%{goal: "Signup|Whatever"}) == expected
# {:matches_member, clauses} filter type
assert get_with_filter.(%{goal: "Signup|Visit /whatever*"}) == expected
end
test "does not return custom events with the filtered pageview goal pathname", %{
conn: conn,
site: site
} do
populate_stats(site, [
build(:pageview, pathname: "/"),
build(:pageview, pathname: "/register"),
build(:event, pathname: "/register", name: "Signup")
])
insert(:goal, %{site: site, page_path: "/register"})
insert(:goal, %{site: site, page_path: "/regi**"})
insert(:goal, %{site: site, event_name: "Signup"})
get_with_filter = fn filters ->
path = "/api/stats/#{site.domain}/conversions"
query = "?period=day&filters=#{Jason.encode!(filters)}"
get(conn, path <> query)
|> json_response(200)
end
expected = [
%{
"name" => "Visit /register",
"visitors" => 1,
"events" => 1,
"conversion_rate" => 33.3
},
%{
"name" => "Visit /regi**",
"visitors" => 1,
"events" => 1,
"conversion_rate" => 33.3
}
]
# {:is, {:page, path}} filter type
assert get_with_filter.(%{goal: "Visit+/register"}) == expected
# {:matches, {:page, expr}} filter type
assert get_with_filter.(%{goal: "Visit+/regi**"}) == expected
# {:member, clauses} filter type
assert get_with_filter.(%{goal: "Visit+/register|Whatever"}) == expected
# {:matches_member, clauses} filter type
assert get_with_filter.(%{goal: "Visit+/register|Visit+/regi**"}) == expected
end
test "can filter by multiple mixed goals", %{conn: conn, site: site} do
@ -405,45 +465,6 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
]
end
test "can filter by multiple negated mixed goals", %{conn: conn, site: site} do
populate_stats(site, [
build(:pageview, pathname: "/"),
build(:pageview, pathname: "/"),
build(:pageview, pathname: "/another"),
build(:pageview, pathname: "/register"),
build(:event, name: "CTA"),
build(:event, name: "Signup")
])
insert(:goal, %{site: site, page_path: "/register"})
insert(:goal, %{site: site, page_path: "/another"})
insert(:goal, %{site: site, event_name: "CTA"})
insert(:goal, %{site: site, event_name: "Signup"})
filters = Jason.encode!(%{goal: "!Signup|Visit /another"})
conn =
get(
conn,
"/api/stats/#{site.domain}/conversions?period=day&filters=#{filters}"
)
assert json_response(conn, 200) == [
%{
"name" => "CTA",
"visitors" => 1,
"events" => 1,
"conversion_rate" => 16.7
},
%{
"name" => "Visit /register",
"visitors" => 1,
"events" => 1,
"conversion_rate" => 16.7
}
]
end
test "can combine wildcard and no wildcard in matches_member", %{conn: conn, site: site} do
populate_stats(site, [
build(:pageview, pathname: "/blog/post-1"),
@ -515,75 +536,6 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
}
]
end
test "can filter by not_matches_member filter type on goals", %{conn: conn, site: site} do
populate_stats(site, [
build(:pageview, pathname: "/another"),
build(:pageview, pathname: "/another"),
build(:pageview, pathname: "/blog/post-1"),
build(:pageview, pathname: "/blog/post-2"),
build(:event, name: "CTA"),
build(:event, name: "Signup")
])
insert(:goal, %{site: site, page_path: "/blog**"})
insert(:goal, %{site: site, page_path: "/ano**"})
insert(:goal, %{site: site, event_name: "CTA"})
insert(:goal, %{site: site, event_name: "Signup"})
filters = Jason.encode!(%{goal: "!Signup|Visit /blog**"})
conn =
get(
conn,
"/api/stats/#{site.domain}/conversions?period=day&filters=#{filters}"
)
assert json_response(conn, 200) == [
%{
"name" => "Visit /ano**",
"visitors" => 2,
"events" => 2,
"conversion_rate" => 33.3
},
%{
"name" => "CTA",
"visitors" => 1,
"events" => 1,
"conversion_rate" => 16.7
}
]
end
test "can combine wildcard and no wildcard in not_matches_member", %{conn: conn, site: site} do
populate_stats(site, [
build(:pageview, pathname: "/blog/post-1"),
build(:pageview, pathname: "/blog/post-2"),
build(:pageview, pathname: "/billing/upgrade"),
build(:pageview, pathname: "/register")
])
insert(:goal, %{site: site, page_path: "/blog/**"})
insert(:goal, %{site: site, page_path: "/billing/upgrade"})
insert(:goal, %{site: site, page_path: "/register"})
filters = Jason.encode!(%{goal: "!Visit /blog/**|Visit /billing/upgrade"})
conn =
get(
conn,
"/api/stats/#{site.domain}/conversions?period=day&filters=#{filters}"
)
assert json_response(conn, 200) == [
%{
"name" => "Visit /register",
"visitors" => 1,
"events" => 1,
"conversion_rate" => 25
}
]
end
end
describe "GET /api/stats/:domain/conversions - with goal and prop=(none) filter" do

View File

@ -146,7 +146,6 @@ defmodule PlausibleWeb.StatsControllerTest do
assert ~c"cities.csv" in zip
assert ~c"conversions.csv" in zip
assert ~c"countries.csv" in zip
assert ~c"custom_props.csv" in zip
assert ~c"devices.csv" in zip
assert ~c"entry_pages.csv" in zip
assert ~c"exit_pages.csv" in zip
@ -161,15 +160,54 @@ defmodule PlausibleWeb.StatsControllerTest do
assert ~c"utm_terms.csv" in zip
end
test "does not export custom properties when site owner is on a growth plan", %{
test "exports only internally used props in custom_props.csv for a growth plan", %{
conn: conn,
site: site,
user: user
site: site
} do
insert(:growth_subscription, user: user)
response = conn |> get("/" <> site.domain <> "/export") |> response(200)
{:ok, site} = Plausible.Props.allow(site, ["author"])
site = Repo.preload(site, :owner)
insert(:growth_subscription, user: site.owner)
populate_stats(site, [
build(:pageview, "meta.key": ["author"], "meta.value": ["a"]),
build(:event, name: "File Download", "meta.key": ["url"], "meta.value": ["b"])
])
conn = get(conn, "/" <> site.domain <> "/export?period=day")
assert response = response(conn, 200)
{:ok, zip} = :zip.unzip(response, [:memory])
{_filename, result} =
Enum.find(zip, fn {filename, _data} -> filename == ~c"custom_props.csv" end)
assert parse_csv(result) == [
["property", "value", "visitors", "events", "percentage"],
["url", "b", "1", "1", "50.0"],
["url", "(none)", "1", "1", "50.0"],
[""]
]
end
test "does not include custom_props.csv for a growth plan if no internal props used", %{
conn: conn,
site: site
} do
{:ok, site} = Plausible.Props.allow(site, ["author"])
site = Repo.preload(site, :owner)
insert(:growth_subscription, user: site.owner)
populate_stats(site, [
build(:pageview, "meta.key": ["author"], "meta.value": ["a"])
])
{:ok, zip} =
conn
|> get("/#{site.domain}/export?period=day")
|> response(200)
|> :zip.unzip([:memory])
files = Map.new(zip)
refute Map.has_key?(files, ~c"custom_props.csv")
@ -190,7 +228,7 @@ defmodule PlausibleWeb.StatsControllerTest do
|> response(400)
end
test "exports allowed event props", %{conn: conn, site: site} do
test "exports allowed event props for a trial account", %{conn: conn, site: site} do
{:ok, site} = Plausible.Props.allow(site, ["author", "logged_in"])
populate_stats(site, [

View File

@ -247,13 +247,14 @@ defmodule PlausibleWeb.EmailTest do
describe "approaching accept_traffic_until" do
test "renders first warning" do
user = build(:user)
user = build(:user, name: "John Doe")
%{html_body: body, subject: subject} =
PlausibleWeb.Email.approaching_accept_traffic_until(user)
assert subject == "We'll stop counting your stats"
assert body =~ plausible_link()
assert body =~ "Hey John,"
assert body =~
"We've noticed that you're still sending us stats so we're writing to inform you that we'll stop accepting stats from your sites next week."

View File

@ -12,6 +12,7 @@ defmodule Plausible.Workers.AcceptTrafficUntilTest do
tomorrow = today |> Date.add(+1)
user1 = insert(:user, accept_traffic_until: next_week)
user2 = insert(:user, accept_traffic_until: tomorrow)
_site1 = insert(:site, members: [user1])
@ -24,6 +25,37 @@ defmodule Plausible.Workers.AcceptTrafficUntilTest do
refute_notifications(user2.email)
end
test "does not send any notifications when site has stats older than 2d" do
today = Date.utc_today()
next_week = today |> Date.add(+7)
user = insert(:user, accept_traffic_until: next_week)
:site
|> insert(members: [user])
|> populate_stats([build(:pageview, timestamp: Date.add(today, -3))])
{:ok, 1} = AcceptTrafficUntil.perform(nil)
refute_notifications(user.email)
end
test "does send notification when last stat is 2d old" do
today = Date.utc_today()
next_week = today |> Date.add(+7)
user =
insert(:user, accept_traffic_until: next_week)
:site
|> insert(members: [user])
|> populate_stats([build(:pageview, timestamp: Date.add(today, -2))])
{:ok, 1} = AcceptTrafficUntil.perform(nil)
assert_weekly_notification(user.email)
end
test "tomorrow: sends one e-mail" do
tomorrow = Date.utc_today() |> Date.add(+1)
user = insert(:user, accept_traffic_until: tomorrow)
@ -107,16 +139,19 @@ defmodule Plausible.Workers.AcceptTrafficUntilTest do
defp assert_weekly_notification(email) when is_binary(email) do
assert_email_delivered_with(
html_body: ~r/Hey Jane,/,
to: [nil: email],
subject: PlausibleWeb.Email.approaching_accept_traffic_until(%{email: email}).subject
subject:
PlausibleWeb.Email.approaching_accept_traffic_until(%{name: "", email: email}).subject
)
end
defp assert_final_notification(email) when is_binary(email) do
assert_email_delivered_with(
html_body: ~r/Hey Jane,/,
to: [nil: email],
subject:
PlausibleWeb.Email.approaching_accept_traffic_until_tomorrow(%{email: email}).subject
PlausibleWeb.Email.approaching_accept_traffic_until_tomorrow(%{name: "", email: email}).subject
)
end