defmodule BerrypodWeb.AnalyticsExportController do use BerrypodWeb, :controller alias Berrypod.Analytics @periods %{ "today" => 0, "7d" => 6, "30d" => 29, "12m" => 364 } @filterable ~w(pathname referrer_source country_code browser os screen_size) def export(conn, params) do period = if Map.has_key?(@periods, params["period"]), do: params["period"], else: "30d" filters = parse_filters(params) range = date_range(period) trend_data = if period == "today", do: Analytics.visitors_by_hour(range, filters), else: Analytics.visitors_by_date(range, filters) visitors = Analytics.count_visitors(range, filters) pageviews = Analytics.count_pageviews(range, filters) bounce_rate = Analytics.bounce_rate(range, filters) avg_duration = Analytics.avg_duration(range, filters) top_pages = Analytics.top_pages(range, filters: filters, limit: 10_000) entry_pages = Analytics.entry_pages(range, filters: filters, limit: 10_000) exit_pages = Analytics.exit_pages(range, filters: filters, limit: 10_000) top_sources = Analytics.top_sources(range, filters: filters, limit: 10_000) top_referrers = Analytics.top_referrers(range, filters: filters, limit: 10_000) countries = Analytics.top_countries(range, filters: filters, limit: 10_000) browsers = Analytics.device_breakdown(range, :browser, filters) oses = Analytics.device_breakdown(range, :os, filters) screen_sizes = Analytics.device_breakdown(range, :screen_size, filters) funnel = Analytics.funnel(range, filters) revenue = Analytics.total_revenue(range, filters) csv_files = [ {"overview.csv", overview_csv(visitors, pageviews, bounce_rate, avg_duration)}, {trend_filename(period), trend_csv(trend_data, period)}, {"pages.csv", pages_csv(top_pages)}, {"entry_pages.csv", entry_exit_csv(entry_pages)}, {"exit_pages.csv", entry_exit_csv(exit_pages)}, {"sources.csv", sources_csv(top_sources)}, {"referrers.csv", referrers_csv(top_referrers)}, {"countries.csv", countries_csv(countries)}, {"browsers.csv", devices_csv(browsers)}, {"operating_systems.csv", devices_csv(oses)}, {"screen_sizes.csv", devices_csv(screen_sizes)}, {"funnel.csv", funnel_csv(funnel, revenue)} ] zip_entries = Enum.map(csv_files, fn {name, content} -> {String.to_charlist(name), content} end) {:ok, {_name, zip_data}} = :zip.create(~c"export.zip", zip_entries, [:memory]) today = Date.to_iso8601(Date.utc_today()) filename = "analytics-#{period}-#{today}.zip" conn |> put_resp_content_type("application/zip") |> put_resp_header("content-disposition", ~s(attachment; filename="#{filename}")) |> send_resp(200, zip_data) end # -- CSV builders -- defp overview_csv(visitors, pageviews, bounce_rate, avg_duration) do encode_csv([ ["metric", "value"], ["visitors", visitors], ["pageviews", pageviews], ["bounce_rate_pct", bounce_rate], ["avg_duration_seconds", avg_duration] ]) end defp trend_filename("today"), do: "visitors_by_hour.csv" defp trend_filename(_), do: "visitors_by_date.csv" defp trend_csv(data, "today") do rows = [["hour", "visitors"] | Enum.map(data, fn r -> [r.hour, r.visitors] end)] encode_csv(rows) end defp trend_csv(data, _period) do rows = [["date", "visitors"] | Enum.map(data, fn r -> [r.date, r.visitors] end)] encode_csv(rows) end defp pages_csv(rows) do encode_csv([ ["pathname", "visitors", "pageviews"] | Enum.map(rows, fn r -> [r.pathname, r.visitors, r.pageviews] end) ]) end defp entry_exit_csv(rows) do encode_csv([ ["pathname", "sessions"] | Enum.map(rows, fn r -> [r.pathname, r.sessions] end) ]) end defp sources_csv(rows) do encode_csv([ ["source", "visitors"] | Enum.map(rows, fn r -> [r.source, r.visitors] end) ]) end defp referrers_csv(rows) do encode_csv([ ["referrer", "visitors"] | Enum.map(rows, fn r -> [r.referrer, r.visitors] end) ]) end defp countries_csv(rows) do encode_csv([ ["country_code", "visitors"] | Enum.map(rows, fn r -> [r.country_code, r.visitors] end) ]) end defp devices_csv(rows) do encode_csv([ ["name", "visitors"] | Enum.map(rows, fn r -> [r.name, r.visitors] end) ]) end defp funnel_csv(funnel, revenue) do top = funnel.product_views steps = [ {"product_views", funnel.product_views}, {"add_to_carts", funnel.add_to_carts}, {"checkouts", funnel.checkouts}, {"purchases", funnel.purchases} ] step_rows = Enum.map(steps, fn {name, count} -> pct = if top > 0, do: Float.round(count / top * 100, 1), else: 0.0 [name, count, pct] end) encode_csv( [["step", "count", "conversion_pct"]] ++ step_rows ++ [["revenue_pence", revenue, ""]] ) end # -- CSV encoding -- defp encode_csv(rows) do rows |> Enum.map(fn row -> row |> Enum.map(&encode_field/1) |> Enum.join(",") end) |> Enum.join("\r\n") |> then(&(&1 <> "\r\n")) end defp encode_field(nil), do: "" defp encode_field(val) when is_binary(val) do if String.contains?(val, [",", "\"", "\n", "\r"]) do ~s("#{String.replace(val, "\"", "\"\"")}") else val end end defp encode_field(val), do: to_string(val) # -- Helpers -- defp date_range(period) do days = Map.fetch!(@periods, period) today = Date.utc_today() start_date = Date.add(today, -days) end_date = Date.add(today, 1) {DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC"), DateTime.new!(end_date, ~T[00:00:00], "Etc/UTC")} end defp parse_filters(params) do filter_params = Map.get(params, "filter", %{}) Enum.reduce(filter_params, %{}, fn {key, val}, acc -> if key in @filterable do Map.put(acc, String.to_existing_atom(key), val) else acc end end) end end