defmodule PlausibleWeb.Api.StatsController do use Plausible use PlausibleWeb, :controller use Plausible.Repo use PlausibleWeb.Plugs.ErrorHandler alias Plausible.Imported.SiteImport alias Plausible.Stats alias Plausible.Stats.{Query, Comparisons} alias PlausibleWeb.Api.Helpers, as: H require Logger @revenue_metrics on_full_build(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: []) plug(:validate_common_input) @doc """ Returns a time-series based on given parameters. ## Parameters This API accepts the following parameters: * `period` - x-axis of the graph, e.g. `12mo`, `day`, `custom`. * `metric` - y-axis of the graph, e.g. `visits`, `visitors`, `pageviews`. See the Stats API ["Metrics"](https://plausible.io/docs/stats-api#metrics) section for more details. Defaults to `visitors`. * `interval` - granularity of the time-series data. You can think of it as a `GROUP BY` clause. Possible values are `minute`, `hour`, `date`, `week`, and `month`. The default depends on the `period` parameter. Check `Plausible.Query.from/2` for each default. * `filters` - optional filters to drill down data. See the Stats API ["Filtering"](https://plausible.io/docs/stats-api#filtering) section for more details. * `with_imported` - boolean indicating whether to include Google Analytics imported data or not. Defaults to `false`. Full example: ```elixir %{ "from" => "2021-09-06", "interval" => "month", "metric" => "visitors", "period" => "custom", "to" => "2021-12-13" } ``` ## Response Returns a map with the following keys: * `plot` - list of values for the requested metric representing the y-axis of the graph. * `labels` - list of date times representing the x-axis of the graph. * `present_index` - index of the element representing the current date in `labels` and `plot` lists. * `interval` - the interval used for querying. * `with_imported` - boolean indicating whether the Google Analytics data was queried or not. * `imported_source` - the source of the imported data, when applicable. Currently only Google Analytics is supported. * `full_intervals` - map of dates indicating whether the interval has been cut off by the requested date range or not. For example, if looking at a month week-by-week, some weeks may be cut off by the month boundaries. It's useful to adjust the graph display slightly in case the interval is not 'full' so that the user understands why the numbers might be lower for those partial periods. Full example: ```elixir %{ "full_intervals" => %{ "2021-09-01" => false, "2021-10-01" => true, "2021-11-01" => true, "2021-12-01" => false }, "imported_source" => nil, "interval" => "month", "labels" => ["2021-09-01", "2021-10-01", "2021-11-01", "2021-12-01"], "plot" => [0, 0, 0, 0], "present_index" => nil, "with_imported" => false } ``` """ def main_graph(conn, params) do site = conn.assigns[:site] with :ok <- validate_params(site, params) do query = Query.from(site, params) selected_metric = if !params["metric"] || params["metric"] == "conversions" do :visitors else String.to_existing_atom(params["metric"]) end timeseries_query = if query.period == "realtime" do %Query{query | period: "30m"} else query end timeseries_result = Stats.timeseries(site, timeseries_query, [selected_metric]) comparison_opts = parse_comparison_opts(params) {comparison_query, comparison_result} = case Comparisons.compare(site, query, params["comparison"], comparison_opts) do {:ok, comparison_query} -> {comparison_query, Stats.timeseries(site, comparison_query, [selected_metric])} {:error, :not_supported} -> {nil, nil} end labels = label_timeseries(timeseries_result, comparison_result) present_index = present_index_for(site, query, labels) full_intervals = build_full_intervals(query, labels) site_import = Plausible.Imported.get_earliest_import(site) json(conn, %{ plot: plot_timeseries(timeseries_result, selected_metric), labels: labels, comparison_plot: comparison_result && plot_timeseries(comparison_result, selected_metric), comparison_labels: comparison_result && label_timeseries(comparison_result, nil), present_index: present_index, interval: query.interval, with_imported: with_imported?(query, comparison_query), imported_source: site_import && SiteImport.label(site_import), full_intervals: full_intervals }) else {:error, message} when is_binary(message) -> bad_request(conn, message) end end defp plot_timeseries(timeseries, metric) do Enum.map(timeseries, fn row -> case row[metric] do nil -> 0 %Money{} = money -> Decimal.to_float(money.amount) value -> value end end) end defp label_timeseries(main_result, nil) do Enum.map(main_result, & &1.date) end @blank_value "__blank__" defp label_timeseries(main_result, comparison_result) do blanks_to_fill = Enum.count(comparison_result) - Enum.count(main_result) if blanks_to_fill > 0 do blanks = List.duplicate(@blank_value, blanks_to_fill) Enum.map(main_result, & &1.date) ++ blanks else Enum.map(main_result, & &1.date) end end defp build_full_intervals(%{interval: "week", date_range: range}, labels) do for label <- labels, into: %{} do interval_start = Timex.beginning_of_week(label) interval_end = Timex.end_of_week(label) within_interval? = Enum.member?(range, interval_start) && Enum.member?(range, interval_end) {label, within_interval?} end end defp build_full_intervals(%{interval: "month", date_range: range}, labels) do for label <- labels, into: %{} do interval_start = Timex.beginning_of_month(label) interval_end = Timex.end_of_month(label) within_interval? = Enum.member?(range, interval_start) && Enum.member?(range, interval_end) {label, within_interval?} end end defp build_full_intervals(_query, _labels) do nil end def top_stats(conn, params) do site = conn.assigns[:site] with :ok <- validate_params(site, params) do query = Query.from(site, params) comparison_opts = parse_comparison_opts(params) comparison_query = case Stats.Comparisons.compare(site, query, params["comparison"], comparison_opts) do {:ok, query} -> query {:error, _cause} -> nil end {top_stats, sample_percent} = fetch_top_stats(site, query, comparison_query) site_import = Plausible.Imported.get_earliest_import(site) json(conn, %{ top_stats: top_stats, interval: query.interval, sample_percent: sample_percent, with_imported: with_imported?(query, comparison_query), imported_source: site_import && SiteImport.label(site_import), comparing_from: comparison_query && comparison_query.date_range.first, comparing_to: comparison_query && comparison_query.date_range.last, from: query.date_range.first, to: query.date_range.last }) else {:error, message} when is_binary(message) -> bad_request(conn, message) end end defp present_index_for(site, query, dates) do case query.interval do "hour" -> current_date = Timex.now(site.timezone) |> Timex.format!("{YYYY}-{0M}-{0D} {h24}:00:00") Enum.find_index(dates, &(&1 == current_date)) "date" -> current_date = Timex.now(site.timezone) |> Timex.to_date() Enum.find_index(dates, &(&1 == current_date)) "week" -> current_date = Timex.now(site.timezone) |> Timex.to_date() |> date_or_weekstart(query) Enum.find_index(dates, &(&1 == current_date)) "month" -> current_date = Timex.now(site.timezone) |> Timex.to_date() |> Timex.beginning_of_month() Enum.find_index(dates, &(&1 == current_date)) "minute" -> current_date = Timex.now(site.timezone) |> Timex.format!("{YYYY}-{0M}-{0D} {h24}:{0m}:00") Enum.find_index(dates, &(&1 == current_date)) end end defp date_or_weekstart(date, query) do weekstart = Timex.beginning_of_week(date) if Enum.member?(query.date_range, weekstart) do weekstart else date end end defp fetch_top_stats( site, %Query{period: "realtime", filters: %{"event:goal" => _goal}} = query, _comparison_query ) do query_30m = %Query{query | period: "30m"} %{ visitors: %{value: unique_conversions}, events: %{value: total_conversions} } = Stats.aggregate(site, query_30m, [:visitors, :events]) stats = [ %{ name: "Current visitors", value: Stats.current_visitors(site) }, %{ name: "Unique conversions (last 30 min)", value: unique_conversions }, %{ name: "Total conversions (last 30 min)", value: total_conversions } ] {stats, 100} end defp fetch_top_stats(site, %Query{period: "realtime"} = query, _comparison_query) do query_30m = %Query{query | period: "30m"} %{ visitors: %{value: visitors}, pageviews: %{value: pageviews} } = Stats.aggregate(site, query_30m, [:visitors, :pageviews]) stats = [ %{ name: "Current visitors", value: Stats.current_visitors(site) }, %{ name: "Unique visitors (last 30 min)", value: visitors }, %{ name: "Pageviews (last 30 min)", value: pageviews } ] {stats, 100} end defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _}} = query, comparison_query) do query_without_filters = Query.remove_event_filters(query, [:goal, :props]) metrics = [:visitors, :events] ++ @revenue_metrics results_without_filters = site |> Stats.aggregate(query_without_filters, [:visitors]) |> transform_keys(%{visitors: :unique_visitors}) results = site |> Stats.aggregate(query, metrics) |> transform_keys(%{visitors: :converted_visitors, events: :completions}) |> Map.merge(results_without_filters) comparison = if comparison_query do comparison_query_without_filters = Query.remove_event_filters(comparison_query, [:goal, :props]) comparison_without_filters = site |> Stats.aggregate(comparison_query_without_filters, [:visitors]) |> transform_keys(%{visitors: :unique_visitors}) site |> Stats.aggregate(comparison_query, metrics) |> transform_keys(%{visitors: :converted_visitors, events: :completions}) |> Map.merge(comparison_without_filters) end conversion_rate = %{ cr: %{value: calculate_cr(results.unique_visitors.value, results.converted_visitors.value)} } comparison_conversion_rate = if comparison do value = calculate_cr(comparison.unique_visitors.value, comparison.converted_visitors.value) %{cr: %{value: value}} else nil end [ top_stats_entry(results, comparison, "Unique visitors", :unique_visitors), top_stats_entry(results, comparison, "Unique conversions", :converted_visitors), top_stats_entry(results, comparison, "Total conversions", :completions), on_full_build do top_stats_entry(results, comparison, "Average revenue", :average_revenue, &format_money/1) end, on_full_build do top_stats_entry(results, comparison, "Total revenue", :total_revenue, &format_money/1) end, top_stats_entry(conversion_rate, comparison_conversion_rate, "Conversion rate", :cr) ] |> Enum.reject(&is_nil/1) |> then(&{&1, 100}) end defp fetch_top_stats(site, query, comparison_query) do metrics = if query.filters["event:page"] do [ :visitors, :visits, :pageviews, :bounce_rate, :time_on_page, :sample_percent ] else [ :visitors, :visits, :pageviews, :views_per_visit, :bounce_rate, :visit_duration, :sample_percent ] end current_results = Stats.aggregate(site, query, metrics) prev_results = comparison_query && Stats.aggregate(site, comparison_query, metrics) stats = [ top_stats_entry(current_results, prev_results, "Unique visitors", :visitors), top_stats_entry(current_results, prev_results, "Total visits", :visits), top_stats_entry(current_results, prev_results, "Total pageviews", :pageviews), top_stats_entry(current_results, prev_results, "Views per visit", :views_per_visit), top_stats_entry(current_results, prev_results, "Bounce rate", :bounce_rate), top_stats_entry(current_results, prev_results, "Visit duration", :visit_duration), top_stats_entry(current_results, prev_results, "Time on page", :time_on_page) ] |> Enum.filter(& &1) {stats, current_results[:sample_percent][:value]} end defp top_stats_entry(current_results, prev_results, name, key, formatter \\ & &1) do if current_results[key] do value = get_in(current_results, [key, :value]) if prev_results do prev_value = get_in(prev_results, [key, :value]) change = Stats.Compare.calculate_change(key, prev_value, value) %{ name: name, value: formatter.(value), comparison_value: formatter.(prev_value), change: change } else %{name: name, value: formatter.(value)} end end end def sources(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) pagination = parse_pagination(params) metrics = if params["detailed"], do: [:visitors, :bounce_rate, :visit_duration], else: [:visitors] res = Stats.breakdown(site, query, "visit:source", metrics, pagination) |> add_cr(site, query, pagination, :source, "visit:source") |> transform_keys(%{source: :name}) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do res |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else res |> to_csv([:name, :visitors, :bounce_rate, :visit_duration]) end else json(conn, res) end end on_full_build do def funnel(conn, %{"id" => funnel_id} = params) do site = Plausible.Repo.preload(conn.assigns.site, :owner) with :ok <- Plausible.Billing.Feature.Funnels.check_availability(site.owner), :ok <- validate_params(site, params), query <- Query.from(site, params), :ok <- validate_funnel_query(query), {funnel_id, ""} <- Integer.parse(funnel_id), {:ok, funnel} <- Stats.funnel(site, query, funnel_id) do json(conn, funnel) else {:error, {:invalid_funnel_query, due_to}} -> bad_request( conn, "We are unable to show funnels when the dashboard is filtered by #{due_to}", %{ level: :normal } ) {:error, :funnel_not_found} -> conn |> put_status(404) |> json(%{error: "Funnel not found"}) |> halt() {:error, :upgrade_required} -> H.payment_required( conn, "#{Plausible.Billing.Feature.Funnels.display_name()} is part of the Plausible Business plan. To get access to this feature, please upgrade your account." ) _ -> bad_request(conn, "There was an error with your request") end end defp validate_funnel_query(query) do case query do _ when is_map_key(query.filters, "event:goal") -> {:error, {:invalid_funnel_query, "goals"}} _ when is_map_key(query.filters, "event:page") -> {:error, {:invalid_funnel_query, "pages"}} _ when query.period == "realtime" -> {:error, {:invalid_funnel_query, "realtime period"}} _ -> :ok end end end def utm_mediums(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) pagination = parse_pagination(params) metrics = [:visitors, :bounce_rate, :visit_duration] res = Stats.breakdown(site, query, "visit:utm_medium", metrics, pagination) |> add_cr(site, query, pagination, :utm_medium, "visit:utm_medium") |> transform_keys(%{utm_medium: :name}) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do res |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else res |> to_csv([:name, :visitors, :bounce_rate, :visit_duration]) end else json(conn, res) end end def utm_campaigns(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) pagination = parse_pagination(params) metrics = [:visitors, :bounce_rate, :visit_duration] res = Stats.breakdown(site, query, "visit:utm_campaign", metrics, pagination) |> add_cr(site, query, pagination, :utm_campaign, "visit:utm_campaign") |> transform_keys(%{utm_campaign: :name}) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do res |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else res |> to_csv([:name, :visitors, :bounce_rate, :visit_duration]) end else json(conn, res) end end def utm_contents(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) pagination = parse_pagination(params) metrics = [:visitors, :bounce_rate, :visit_duration] res = Stats.breakdown(site, query, "visit:utm_content", metrics, pagination) |> add_cr(site, query, pagination, :utm_content, "visit:utm_content") |> transform_keys(%{utm_content: :name}) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do res |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else res |> to_csv([:name, :visitors, :bounce_rate, :visit_duration]) end else json(conn, res) end end def utm_terms(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) pagination = parse_pagination(params) metrics = [:visitors, :bounce_rate, :visit_duration] res = Stats.breakdown(site, query, "visit:utm_term", metrics, pagination) |> add_cr(site, query, pagination, :utm_term, "visit:utm_term") |> transform_keys(%{utm_term: :name}) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do res |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else res |> to_csv([:name, :visitors, :bounce_rate, :visit_duration]) end else json(conn, res) end end def utm_sources(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) pagination = parse_pagination(params) metrics = [:visitors, :bounce_rate, :visit_duration] res = Stats.breakdown(site, query, "visit:utm_source", metrics, pagination) |> add_cr(site, query, pagination, :utm_source, "visit:utm_source") |> transform_keys(%{utm_source: :name}) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do res |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else res |> to_csv([:name, :visitors, :bounce_rate, :visit_duration]) end else json(conn, res) end end def referrers(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) pagination = parse_pagination(params) metrics = [:visitors, :bounce_rate, :visit_duration] res = Stats.breakdown(site, query, "visit:referrer", metrics, pagination) |> add_cr(site, query, pagination, :referrer, "visit:referrer") |> transform_keys(%{referrer: :name}) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do res |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else res |> to_csv([:name, :visitors, :bounce_rate, :visit_duration]) end else json(conn, res) end end def referrer_drilldown(conn, %{"referrer" => "Google"} = params) do site = conn.assigns[:site] |> Repo.preload(:google_auth) query = Query.from(site, params) |> Query.put_filter("visit:source", "Google") search_terms = if site.google_auth && site.google_auth.property && !query.filters["goal"] do google_api().fetch_stats(site, query, params["limit"] || 9) end %{:visitors => %{value: total_visitors}} = Stats.aggregate(site, query, [:visitors]) user_id = get_session(conn, :current_user_id) is_admin = user_id && Plausible.Sites.has_admin_access?(user_id, site) case search_terms do nil -> json(conn, %{not_configured: true, is_admin: is_admin, total_visitors: total_visitors}) {:ok, terms} -> json(conn, %{search_terms: terms, total_visitors: total_visitors}) {:error, _} -> conn |> put_status(502) |> json(%{ not_configured: true, is_admin: is_admin, total_visitors: total_visitors }) end end def referrer_drilldown(conn, %{"referrer" => referrer} = params) do site = conn.assigns[:site] query = Query.from(site, params) |> Query.put_filter("visit:source", referrer) pagination = parse_pagination(params) metrics = if params["detailed"], do: [:visitors, :bounce_rate, :visit_duration], else: [:visitors] referrers = Stats.breakdown(site, query, "visit:referrer", metrics, pagination) |> add_cr(site, query, pagination, :referrer, "visit:referrer") |> transform_keys(%{referrer: :name}) json(conn, referrers) end def pages(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) metrics = if params["detailed"], do: [:visitors, :pageviews, :bounce_rate, :time_on_page], else: [:visitors] pagination = parse_pagination(params) pages = Stats.breakdown(site, query, "event:page", metrics, pagination) |> add_cr(site, query, pagination, :page, "event:page") |> transform_keys(%{page: :name}) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do pages |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else pages |> to_csv([:name, :visitors, :pageviews, :bounce_rate, :time_on_page]) end else json(conn, pages) end end def entry_pages(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) pagination = parse_pagination(params) metrics = [:visitors, :visits, :visit_duration] entry_pages = Stats.breakdown(site, query, "visit:entry_page", metrics, pagination) |> add_cr(site, query, pagination, :entry_page, "visit:entry_page") |> transform_keys(%{entry_page: :name}) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do to_csv(entry_pages, [:name, :visitors, :conversion_rate], [ :name, :conversions, :conversion_rate ]) else to_csv(entry_pages, [:name, :visitors, :visits, :visit_duration], [ :name, :unique_entrances, :total_entrances, :visit_duration ]) end else json(conn, entry_pages) end end def exit_pages(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) {limit, page} = parse_pagination(params) metrics = [:visitors, :visits] exit_pages = Stats.breakdown(site, query, "visit:exit_page", metrics, {limit, page}) |> add_cr(site, query, {limit, page}, :exit_page, "visit:exit_page") |> add_exit_rate(site, query, limit) |> transform_keys(%{exit_page: :name}) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do to_csv(exit_pages, [:name, :visitors, :conversion_rate], [ :name, :conversions, :conversion_rate ]) else to_csv(exit_pages, [:name, :visitors, :visits, :exit_rate], [ :name, :unique_exits, :total_exits, :exit_rate ]) end else json(conn, exit_pages) end end defp add_exit_rate(breakdown_results, site, query, limit) do if Query.has_event_filters?(query) do breakdown_results else pages = Enum.map(breakdown_results, & &1[:exit_page]) total_visits_query = Query.put_filter(query, "event:page", {:member, pages}) |> Query.put_filter("event:name", {:is, "pageview"}) total_pageviews = Stats.breakdown(site, total_visits_query, "event:page", [:pageviews], {limit, 1}) Enum.map(breakdown_results, fn result -> exit_rate = case Enum.find(total_pageviews, &(&1[:page] == result[:exit_page])) do %{pageviews: pageviews} -> Float.floor(result[:visits] / pageviews * 100) nil -> nil end Map.put(result, :exit_rate, exit_rate) end) end end def countries(conn, params) do site = conn.assigns[:site] query = site |> Query.from(params) pagination = parse_pagination(params) countries = Stats.breakdown(site, query, "visit:country", [:visitors], pagination) |> add_cr(site, query, {300, 1}, :country, "visit:country") |> transform_keys(%{country: :code}) |> add_percentages(site, query) if params["csv"] do countries = countries |> Enum.map(fn country -> country_info = get_country(country[:code]) Map.put(country, :name, country_info.name) end) if Map.has_key?(query.filters, "event:goal") do countries |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else countries |> to_csv([:name, :visitors]) end else countries = Enum.map(countries, fn row -> country = get_country(row[:code]) if country do Map.merge(row, %{ name: country.name, flag: country.flag, alpha_3: country.alpha_3, code: country.alpha_2 }) else Map.merge(row, %{ name: row[:code], flag: "", alpha_3: "", code: "" }) end end) json(conn, countries) end end def regions(conn, params) do site = conn.assigns[:site] query = site |> Query.from(params) pagination = parse_pagination(params) regions = Stats.breakdown(site, query, "visit:region", [:visitors], pagination) |> transform_keys(%{region: :code}) |> Enum.map(fn region -> region_entry = Location.get_subdivision(region[:code]) if region_entry do country_entry = get_country(region_entry.country_code) Map.merge(region, %{name: region_entry.name, country_flag: country_entry.flag}) else Logger.warning("Could not find region info - code: #{inspect(region[:code])}") Map.merge(region, %{name: region[:code]}) end end) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do regions |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else regions |> to_csv([:name, :visitors]) end else json(conn, regions) end end def cities(conn, params) do site = conn.assigns[:site] query = site |> Query.from(params) pagination = parse_pagination(params) cities = Stats.breakdown(site, query, "visit:city", [:visitors], pagination) |> transform_keys(%{city: :code}) |> Enum.map(fn city -> city_info = Location.get_city(city[:code]) if city_info do country_info = get_country(city_info.country_code) Map.merge(city, %{ name: city_info.name, country_flag: country_info.flag }) else Logger.warning("Could not find city info - code: #{inspect(city[:code])}") Map.merge(city, %{name: "N/A"}) end end) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do cities |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else cities |> to_csv([:name, :visitors]) end else json(conn, cities) end end def browsers(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) pagination = parse_pagination(params) browsers = Stats.breakdown(site, query, "visit:browser", [:visitors], pagination) |> add_cr(site, query, pagination, :browser, "visit:browser") |> transform_keys(%{browser: :name}) |> add_percentages(site, query) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do browsers |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else browsers |> to_csv([:name, :visitors]) end else json(conn, browsers) end end def browser_versions(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) pagination = parse_pagination(params) versions = Stats.breakdown(site, query, "visit:browser_version", [:visitors], pagination) |> add_cr(site, query, pagination, :browser_version, "visit:browser_version") |> transform_keys(%{browser_version: :name}) |> add_percentages(site, query) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do versions |> transform_keys(%{ name: :version, browser: :name, visitors: :conversions }) |> to_csv([:name, :version, :conversions, :conversion_rate]) else versions |> transform_keys(%{name: :version, browser: :name}) |> to_csv([:name, :version, :visitors]) end else json(conn, versions) end end def operating_systems(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) pagination = parse_pagination(params) systems = Stats.breakdown(site, query, "visit:os", [:visitors], pagination) |> add_cr(site, query, pagination, :os, "visit:os") |> transform_keys(%{os: :name}) |> add_percentages(site, query) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do systems |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else systems |> to_csv([:name, :visitors]) end else json(conn, systems) end end def operating_system_versions(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) pagination = parse_pagination(params) versions = Stats.breakdown(site, query, "visit:os_version", [:visitors], pagination) |> add_cr(site, query, pagination, :os_version, "visit:os_version") |> transform_keys(%{os_version: :name}) |> add_percentages(site, query) json(conn, versions) end def screen_sizes(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) pagination = parse_pagination(params) sizes = Stats.breakdown(site, query, "visit:device", [:visitors], pagination) |> add_cr(site, query, pagination, :device, "visit:device") |> transform_keys(%{device: :name}) |> add_percentages(site, query) if params["csv"] do if Map.has_key?(query.filters, "event:goal") do sizes |> transform_keys(%{visitors: :conversions}) |> to_csv([:name, :conversions, :conversion_rate]) else sizes |> to_csv([:name, :visitors]) end else json(conn, sizes) end end defp calculate_cr(nil, _converted_visitors), do: nil defp calculate_cr(unique_visitors, converted_visitors) do if unique_visitors > 0, do: Float.round(converted_visitors / unique_visitors * 100, 1), else: 0.0 end def conversions(conn, params) do pagination = parse_pagination(params) site = Plausible.Repo.preload(conn.assigns.site, :goals) query = Query.from(site, params) query = if query.period == "realtime" do %Query{query | period: "30m"} else query end total_q = Query.remove_event_filters(query, [:goal, :props]) %{visitors: %{value: total_visitors}} = Stats.aggregate(site, total_q, [:visitors]) metrics = on_full_build do if Enum.any?(site.goals, &Plausible.Goal.Revenue.revenue?/1) and Plausible.Billing.Feature.RevenueGoals.enabled?(site) do [:visitors, :events] ++ @revenue_metrics else [:visitors, :events] end else [:visitors, :events] end conversions = site |> Stats.breakdown(query, "event:goal", metrics, pagination) |> transform_keys(%{goal: :name}) |> Enum.map(fn goal -> goal |> Map.put(:conversion_rate, calculate_cr(total_visitors, goal[:visitors])) |> Enum.map(&format_revenue_metric/1) |> Map.new() end) if params["csv"] do to_csv(conversions, [:name, :visitors, :events], [ :name, :unique_conversions, :total_conversions ]) else json(conn, conversions) end end def custom_prop_values(conn, params) do site = Plausible.Repo.preload(conn.assigns.site, :owner) prop_key = Map.fetch!(params, "prop_key") case Plausible.Props.ensure_prop_key_accessible(prop_key, site.owner) do :ok -> props = breakdown_custom_prop_values(site, params) json(conn, props) {:error, :upgrade_required} -> H.payment_required( conn, "#{Plausible.Billing.Feature.Props.display_name()} is part of the Plausible Business plan. To get access to this feature, please upgrade your account." ) end end def all_custom_prop_values(conn, params) do site = conn.assigns.site query = Query.from(site, params) prop_names = Plausible.Stats.CustomProps.fetch_prop_names(site, query) prop_names = if Plausible.Billing.Feature.Props.enabled?(site) do prop_names else prop_names |> Enum.filter(&(&1 in Plausible.Props.internal_keys())) end 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() 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 pagination = parse_pagination(params) prefixed_prop = "event:props:" <> prop_key query = Query.from(site, params) |> Map.put(:include_imported, false) metrics = if full_build?() and Map.has_key?(query.filters, "event:goal") do [:visitors, :events] ++ @revenue_metrics else [:visitors, :events] end props = Stats.breakdown(site, query, prefixed_prop, metrics, pagination) |> transform_keys(%{prop_key => :name}) |> Enum.map(fn entry -> Enum.map(entry, &format_revenue_metric/1) |> Map.new() end) |> add_percentages(site, query) if Map.has_key?(query.filters, "event:goal") do total_q = Query.remove_event_filters(query, [:goal, :props]) %{visitors: %{value: total_unique_visitors}} = Stats.aggregate(site, total_q, [:visitors]) Enum.map(props, fn prop -> Map.put(prop, :conversion_rate, calculate_cr(total_unique_visitors, prop.visitors)) end) else props end end def current_visitors(conn, _) do site = conn.assigns[:site] json(conn, Stats.current_visitors(site)) end defp google_api(), do: Application.fetch_env!(:plausible, :google_api) def filter_suggestions(conn, params) do site = conn.assigns[:site] query = Query.from(site, params) json( conn, Stats.filter_suggestions(site, query, params["filter_name"], params["q"]) ) end defp transform_keys(result, keys_to_replace) when is_map(result) do for {key, val} <- result, do: {Map.get(keys_to_replace, key, key), val}, into: %{} end defp transform_keys(results, keys_to_replace) when is_list(results) do Enum.map(results, &transform_keys(&1, keys_to_replace)) end defp parse_pagination(params) do limit = to_int(params["limit"], 9) page = to_int(params["page"], 1) {limit, page} end defp to_int(string, default) when is_binary(string) do case Integer.parse(string) do {i, ""} when is_integer(i) -> i _ -> default end end defp to_int(_, default), do: default defp add_percentages([_ | _] = breakdown_result, site, query) when not is_map_key(query.filters, "event:goal") do %{visitors: %{value: total_visitors}} = Stats.aggregate(site, query, [:visitors]) breakdown_result |> Enum.map(fn stat -> Map.put(stat, :percentage, Float.round(stat.visitors / total_visitors * 100, 1)) end) end defp add_percentages(breakdown_result, _, _), do: breakdown_result defp add_cr([_ | _] = breakdown_results, site, query, pagination, key_name, filter_name) when is_map_key(query.filters, "event:goal") do items = Enum.map(breakdown_results, fn item -> Map.fetch!(item, key_name) end) query_without_goal = query |> Query.put_filter(filter_name, {:member, items}) |> Query.remove_event_filters([:goal, :props]) # Here, we're always only interested in the first page of results # - the :member filter makes sure that the results always match with # the items in the given breakdown_results list pagination = {elem(pagination, 0), 1} res_without_goal = Stats.breakdown(site, query_without_goal, filter_name, [:visitors], pagination) Enum.map(breakdown_results, fn item -> without_goal = Enum.find(res_without_goal, fn s -> Map.fetch!(s, key_name) == Map.fetch!(item, key_name) end) item |> Map.put(:total_visitors, without_goal.visitors) |> Map.put(:conversion_rate, calculate_cr(without_goal.visitors, item.visitors)) end) end defp add_cr(breakdown_results, _, _, _, _, _), do: breakdown_results defp to_csv(list, columns), do: to_csv(list, columns, columns) defp to_csv(list, columns, column_names) do list |> Enum.map(fn row -> Enum.map(columns, &row[&1]) end) |> (fn res -> [column_names | res] end).() |> CSV.encode() |> Enum.join() end defp get_country(code) do case Location.get_country(code) do nil -> Logger.warning("Could not find country info - code: #{inspect(code)}") %Location.Country{ alpha_2: code, alpha_3: "N/A", name: code, flag: nil } country -> country end end defp validate_common_input(conn, _opts) do case validate_params(conn.assigns[:site], conn.params) do :ok -> conn {:error, message} when is_binary(message) -> bad_request(conn, message) end end defp validate_params(site, params) do with {:ok, dates} <- validate_dates(params), :ok <- validate_interval(params), do: validate_interval_granularity(site, params, dates) end defp validate_dates(params) do params |> Map.take(["from", "to", "date"]) |> Enum.reduce_while({:ok, %{}}, fn {key, value}, {:ok, acc} -> case Date.from_iso8601(value) do {:ok, date} -> {:cont, {:ok, Map.put(acc, key, date)}} _ -> {:halt, {:error, "Failed to parse '#{key}' argument. Only ISO 8601 dates are allowed, e.g. `2019-09-07`, `2020-01-01`"}} end end) end defp validate_interval(params) do with %{"interval" => interval} <- params, true <- Plausible.Stats.Interval.valid?(interval) do :ok else %{} -> :ok false -> values = Enum.join(Plausible.Stats.Interval.list(), ", ") {:error, "Invalid value for interval. Accepted values are: #{values}"} end end defp validate_interval_granularity(site, params, dates) do case params do %{"interval" => interval, "period" => "custom", "from" => _, "to" => _} -> if Plausible.Stats.Interval.valid_for_period?("custom", interval, site: site, from: dates["from"], to: dates["to"] ) do :ok else {:error, "Invalid combination of interval and period. Custom ranges over 12 months must come with greater granularity, e.g. `period=custom,interval=week`"} end %{"interval" => interval, "period" => period} -> if Plausible.Stats.Interval.valid_for_period?(period, interval, site: site) do :ok else {:error, "Invalid combination of interval and period. Interval must be smaller than the selected period, e.g. `period=day,interval=minute`"} end _ -> :ok end end defp bad_request(conn, message, extra \\ %{}) do payload = Map.merge(extra, %{error: message}) conn |> put_status(400) |> json(payload) |> halt() end defp parse_comparison_opts(params) do [ from: params["compare_from"], to: params["compare_to"], match_day_of_week?: params["match_day_of_week"] == "true" ] end defp with_imported?(source_query, comparison_query) do cond do source_query.include_imported -> true comparison_query && comparison_query.include_imported -> true true -> false end end on_full_build do defdelegate format_revenue_metric(metric_value), to: PlausibleWeb.Controllers.API.Revenue defdelegate format_money(money), to: PlausibleWeb.Controllers.API.Revenue else defp format_revenue_metric({metric, value}) do {metric, value} end end end