defmodule Plausible.Stats.Sparkline do @moduledoc """ Module used to generate sparkline overview data for sites, including consolidated views. The sparkline graphs are used in site index and in CRM. """ alias Plausible.{Site, Stats} alias Plausible.Stats.QueryBuilder @spec parallel_overview([Site.t()], NaiveDateTime.t()) :: map() def parallel_overview(sites, now \\ NaiveDateTime.utc_now()) when is_list(sites) do Task.async_stream( sites, fn site -> {site.domain, safe_overview_24h(site, now)} end, timeout: 5000, on_timeout: :kill_task, max_concurrency: 4 ) |> Enum.reduce(%{}, fn {:ok, {domain, {:ok, stats}}}, acc -> Map.put(acc, domain, stats) _, acc -> acc end) end @spec overview_24h(Site.t(), NaiveDateTime.t()) :: map() def overview_24h(view_or_site, now \\ NaiveDateTime.utc_now()) do stats = query_24h_stats(view_or_site, now) intervals = query_24h_intervals(view_or_site, now) Map.merge(stats, intervals) end @spec safe_overview_24h(Site.t(), NaiveDateTime.t()) :: {:ok, map()} | {:error, :inaccessible} | {:error, :not_found} def safe_overview_24h(site, now \\ NaiveDateTime.utc_now()) def safe_overview_24h(nil, _now), do: {:error, :not_found} def safe_overview_24h(%Site{} = site, now) do try do {:ok, overview_24h(site, now)} catch kind, reason -> message = Exception.format(kind, reason, __STACKTRACE__) Sentry.capture_message( "Could not render overview 24h - site will be kept in loading indefinitely", extra: %{ error: message, site: site.domain, comment: "Probing error volume" } ) {:error, :inaccessible} end end def empty_24h_intervals(now \\ NaiveDateTime.utc_now()) do first = NaiveDateTime.add(now, -24, :hour) {:ok, time} = Time.new(first.hour, 0, 0) first = NaiveDateTime.new!(NaiveDateTime.to_date(first), time) for offset <- 0..24 do %{interval: to_string(NaiveDateTime.add(first, offset, :hour)), visitors: 0} end end defp query_24h_stats(view_or_site, now) do if Plausible.Sites.regular?(view_or_site) do query_24h_stats_regular(view_or_site, now) else query_24h_stats_consolidated(view_or_site, now) end end defp query_24h_stats_regular(site, now) do stats_query = QueryBuilder.build!(site, now: DateTime.from_naive!(now, "Etc/UTC"), input_date_range: :"24h", metrics: [:visitors], include: [compare: :previous_period] ) %Stats.QueryResult{ results: [ %{ metrics: [visitors], comparison: %{ change: [visitors_change] } } ] } = Stats.query(site, stats_query) %{ visitors: visitors, visitors_change: visitors_change } end defp query_24h_stats_consolidated(view, now) do stats_query = QueryBuilder.build!(view, now: DateTime.from_naive!(now, "Etc/UTC"), input_date_range: :"24h", metrics: [:visitors, :visits, :pageviews, :views_per_visit], include: [compare: :previous_period] ) %Stats.QueryResult{ results: [ %{ metrics: [visitors, visits, pageviews, views_per_visit], comparison: %{ change: [visitors_change, visits_change, pageviews_change, views_per_visit_change] } } ] } = Stats.query(view, stats_query) %{ visitors: visitors, visits: visits, pageviews: pageviews, views_per_visit: views_per_visit, visitors_change: visitors_change, visits_change: visits_change, pageviews_change: pageviews_change, views_per_visit_change: views_per_visit_change } end defp query_24h_intervals(view_or_site, now) do graph_query = QueryBuilder.build!(view_or_site, now: DateTime.from_naive!(now, "Etc/UTC"), metrics: [:visitors], input_date_range: :"24h", dimensions: ["time:hour"], order_by: [{"time:hour", :asc}], include: [time_labels: true] ) %Stats.QueryResult{results: results, meta: %{values: [time_labels: time_labels]}} = Stats.query(view_or_site, graph_query) visitors_by_timestamp = Map.new(results, fn %{dimensions: [timestamp], metrics: [visitors]} -> {timestamp, visitors} end) %{ intervals: Enum.map(time_labels, fn timestamp -> %{interval: timestamp, visitors: Map.get(visitors_by_timestamp, timestamp, 0)} end) } end end