| defmodule Plausible.Imported.GoogleAnalytics4 do |
| @moduledoc """ |
| Import implementation for Google Analytics 4. |
| """ |
|
|
| use Plausible.Imported.Importer |
|
|
| alias Plausible.Imported |
| alias Plausible.Repo |
|
|
| @recoverable_errors [:rate_limit_exceeded, :socket_failed, :server_failed] |
| @missing_values ["(none)", "(not set)", "(not provided)", "(other)"] |
|
|
| @impl true |
| def name(), do: :google_analytics_4 |
|
|
| @impl true |
| def label(), do: "Google Analytics 4" |
|
|
| @impl true |
| def email_template(), do: "google_analytics_import.html" |
|
|
| @impl true |
| def before_start(site_import, opts) do |
| site_import = Repo.preload(site_import, :site) |
|
|
| if import_id = Keyword.get(opts, :resume_from_import_id) do |
| if existing_site_import = Imported.get_import(site_import.site, import_id) do |
| Repo.delete!(site_import) |
| {:ok, existing_site_import} |
| else |
| |
| {:error, {:no_import_to_resume, import_id}} |
| end |
| else |
| {:ok, site_import} |
| end |
| end |
|
|
| @impl true |
| def parse_args(%{"resume_from_dataset" => dataset, "resume_from_offset" => offset} = args) do |
| args |
| |> Map.drop(["resume_from_dataset", "resume_from_offset"]) |
| |> parse_args() |
| |> Keyword.put(:dataset, dataset) |
| |> Keyword.put(:offset, offset) |
| end |
|
|
| def parse_args( |
| %{"property" => property, "start_date" => start_date, "end_date" => end_date} = args |
| ) do |
| start_date = Date.from_iso8601!(start_date) |
| end_date = Date.from_iso8601!(end_date) |
| date_range = Date.range(start_date, end_date) |
|
|
| auth = { |
| Map.fetch!(args, "access_token"), |
| Map.fetch!(args, "refresh_token"), |
| Map.fetch!(args, "token_expires_at") |
| } |
|
|
| [ |
| property: property, |
| date_range: date_range, |
| auth: auth |
| ] |
| end |
|
|
| @doc """ |
| Imports stats from a Google Analytics 4 property to a Plausible site. |
| |
| This function fetches Google Analytics 4 reports which are then passed in batches |
| to Clickhouse by the `Plausible.Imported.Buffer` process. |
| """ |
| @impl true |
| def import_data(site_import, opts) do |
| date_range = Keyword.fetch!(opts, :date_range) |
| property = Keyword.fetch!(opts, :property) |
| auth = Keyword.fetch!(opts, :auth) |
| flush_interval_ms = Keyword.get(opts, :flush_interval_ms, 1000) |
|
|
| {:ok, buffer} = Plausible.Imported.Buffer.start_link(flush_interval_ms: flush_interval_ms) |
|
|
| persist_fn = fn table, rows -> |
| records = from_report(rows, site_import.site_id, site_import.id, table) |
| Plausible.Imported.Buffer.insert_many(buffer, table, records) |
| end |
|
|
| resume_opts = Keyword.take(opts, [:dataset, :offset]) |
| fetch_opts = Keyword.get(opts, :fetch_opts, []) |
|
|
| try do |
| result = |
| Plausible.Google.GA4.API.import_analytics(date_range, property, auth, |
| persist_fn: persist_fn, |
| fetch_opts: fetch_opts, |
| resume_opts: resume_opts |
| ) |
|
|
| case result do |
| {:error, {error, details}} when error in @recoverable_errors -> |
| site_import = Repo.preload(site_import, [:site, :imported_by]) |
| dataset = Keyword.fetch!(details, :dataset) |
| offset = Keyword.fetch!(details, :offset) |
| {access_token, refresh_token, token_expires_at} = auth |
|
|
| resume_import_opts = [ |
| property: property, |
| label: property, |
| start_date: date_range.first, |
| end_date: date_range.last, |
| access_token: access_token, |
| refresh_token: refresh_token, |
| token_expires_at: token_expires_at, |
| resume_from_import_id: site_import.id, |
| resume_from_dataset: dataset, |
| resume_from_offset: offset, |
| job_opts: [schedule_in: {65, :minutes}, unique: nil] |
| ] |
|
|
| new_import( |
| site_import.site, |
| site_import.imported_by, |
| resume_import_opts |
| ) |
|
|
| {:error, error, skip_purge?: true, skip_mark_failed?: true} |
|
|
| other -> |
| other |
| end |
| after |
| Plausible.Imported.Buffer.flush(buffer) |
| Plausible.Imported.Buffer.stop(buffer) |
| end |
| end |
|
|
| def from_report(nil, _site_id, _import_id, _metric), do: nil |
|
|
| def from_report(data, site_id, import_id, table) do |
| Enum.reduce(data, [], fn row, acc -> |
| if Map.get(row.dimensions, "date") in @missing_values do |
| acc |
| else |
| [new_from_report(site_id, import_id, table, row) | acc] |
| end |
| end) |
| end |
|
|
| defp parse_number(nr) do |
| {float, ""} = Float.parse(nr) |
| round(float) |
| end |
|
|
| defp maybe_override_event_name("file_download"), do: "File Download" |
| defp maybe_override_event_name("click"), do: "Outbound Link: Click" |
| defp maybe_override_event_name(name), do: name |
|
|
| defp new_from_report(site_id, import_id, "imported_visitors", row) do |
| %{ |
| site_id: site_id, |
| import_id: import_id, |
| date: get_date(row), |
| visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(), |
| pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(), |
| bounces: row.metrics |> Map.fetch!("bounces") |> parse_number(), |
| visits: row.metrics |> Map.fetch!("sessions") |> parse_number(), |
| visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number() |
| } |
| end |
|
|
| defp new_from_report(site_id, import_id, "imported_sources", row) do |
| %{ |
| site_id: site_id, |
| import_id: import_id, |
| date: get_date(row), |
| source: row.dimensions |> Map.fetch!("sessionSource") |> parse_source(), |
| |
| channel: row.dimensions |> Map.fetch!("sessionDefaultChannelGroup"), |
| referrer: nil, |
| |
| utm_source: nil, |
| utm_medium: row.dimensions |> Map.fetch!("sessionMedium") |> default_if_missing(), |
| utm_campaign: row.dimensions |> Map.fetch!("sessionCampaignName") |> default_if_missing(), |
| utm_content: row.dimensions |> Map.fetch!("sessionManualAdContent") |> default_if_missing(), |
| utm_term: row.dimensions |> Map.fetch!("sessionGoogleAdsKeyword") |> default_if_missing(), |
| visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(), |
| visits: row.metrics |> Map.fetch!("sessions") |> parse_number(), |
| pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(), |
| bounces: row.metrics |> Map.fetch!("bounces") |> parse_number(), |
| visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number() |
| } |
| end |
|
|
| defp new_from_report(site_id, import_id, "imported_pages", row) do |
| %{ |
| site_id: site_id, |
| import_id: import_id, |
| date: get_date(row), |
| hostname: row.dimensions |> Map.fetch!("hostName") |> String.replace_prefix("www.", ""), |
| page: row.dimensions |> Map.fetch!("pagePath") |> URI.parse() |> Map.get(:path), |
| visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(), |
| visits: row.metrics |> Map.fetch!("sessions") |> parse_number(), |
| pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(), |
| |
| exits: 0, |
| total_time_on_page: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number(), |
| total_time_on_page_visits: row.metrics |> Map.fetch!("sessions") |> parse_number() |
| } |
| end |
|
|
| defp new_from_report(site_id, import_id, "imported_entry_pages", row) do |
| %{ |
| site_id: site_id, |
| import_id: import_id, |
| date: get_date(row), |
| entry_page: row.dimensions |> Map.fetch!("landingPage"), |
| visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(), |
| entrances: row.metrics |> Map.fetch!("sessions") |> parse_number(), |
| visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number(), |
| pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(), |
| bounces: row.metrics |> Map.fetch!("bounces") |> parse_number() |
| } |
| end |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| defp new_from_report(site_id, import_id, "imported_custom_events", row) do |
| %{ |
| site_id: site_id, |
| import_id: import_id, |
| date: get_date(row), |
| name: row.dimensions |> Map.fetch!("eventName") |> maybe_override_event_name(), |
| link_url: row.dimensions |> Map.fetch!("linkUrl"), |
| visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(), |
| events: row.metrics |> Map.fetch!("eventCount") |> parse_number() |
| } |
| end |
|
|
| defp new_from_report(site_id, import_id, "imported_locations", row) do |
| country_code = row.dimensions |> Map.fetch!("countryId") |> default_if_missing("") |
| city_name = row.dimensions |> Map.fetch!("city") |> default_if_missing("") |
| city_data = Location.get_city(city_name, country_code) |
|
|
| %{ |
| site_id: site_id, |
| import_id: import_id, |
| date: get_date(row), |
| country: country_code, |
| region: row.dimensions |> Map.fetch!("region") |> default_if_missing(""), |
| city: city_data && city_data.id, |
| visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(), |
| visits: row.metrics |> Map.fetch!("sessions") |> parse_number(), |
| pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(), |
| bounces: row.metrics |> Map.fetch!("bounces") |> parse_number(), |
| visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number() |
| } |
| end |
|
|
| defp new_from_report(site_id, import_id, "imported_devices", row) do |
| %{ |
| site_id: site_id, |
| import_id: import_id, |
| date: get_date(row), |
| device: row.dimensions |> Map.fetch!("deviceCategory") |> String.capitalize(), |
| visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(), |
| visits: row.metrics |> Map.fetch!("sessions") |> parse_number(), |
| pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(), |
| bounces: row.metrics |> Map.fetch!("bounces") |> parse_number(), |
| visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number() |
| } |
| end |
|
|
| @browser_google_to_plausible %{ |
| "User-Agent:Opera" => "Opera", |
| "Mozilla Compatible Agent" => "Mobile App", |
| "Android Webview" => "Mobile App", |
| "Android Browser" => "Mobile App", |
| "Safari (in-app)" => "Mobile App", |
| "User-Agent: Mozilla" => "Firefox", |
| "(not set)" => "" |
| } |
|
|
| defp new_from_report(site_id, import_id, "imported_browsers", row) do |
| browser = Map.fetch!(row.dimensions, "browser") |
|
|
| %{ |
| site_id: site_id, |
| import_id: import_id, |
| date: get_date(row), |
| browser: Map.get(@browser_google_to_plausible, browser, browser), |
| |
| browser_version: nil, |
| visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(), |
| visits: row.metrics |> Map.fetch!("sessions") |> parse_number(), |
| pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(), |
| bounces: row.metrics |> Map.fetch!("bounces") |> parse_number(), |
| visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number() |
| } |
| end |
|
|
| @os_google_to_plausible %{ |
| "Macintosh" => "Mac", |
| "Linux" => "GNU/Linux", |
| "(not set)" => "" |
| } |
|
|
| defp new_from_report(site_id, import_id, "imported_operating_systems", row) do |
| os = Map.fetch!(row.dimensions, "operatingSystem") |
|
|
| %{ |
| site_id: site_id, |
| import_id: import_id, |
| date: get_date(row), |
| operating_system: Map.get(@os_google_to_plausible, os, os), |
| operating_system_version: row.dimensions |> Map.fetch!("operatingSystemVersion"), |
| visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(), |
| visits: row.metrics |> Map.fetch!("sessions") |> parse_number(), |
| pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(), |
| bounces: row.metrics |> Map.fetch!("bounces") |> parse_number(), |
| visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number() |
| } |
| end |
|
|
| defp get_date(%{dimensions: %{"date" => date}}) do |
| date |
| |> Plausible.Times.parse!("%Y%m%d", :strftime) |
| |> NaiveDateTime.to_date() |
| end |
|
|
| defp default_if_missing(value, default \\ nil) |
| defp default_if_missing(value, default) when value in @missing_values, do: default |
| defp default_if_missing(value, _default), do: value |
|
|
| defp parse_source(nil), do: nil |
| defp parse_source("(direct)"), do: nil |
| defp parse_source("google"), do: "Google" |
| defp parse_source("bing"), do: "Bing" |
| defp parse_source("duckduckgo"), do: "DuckDuckGo" |
|
|
| defp parse_source(ref) do |
| Plausible.Ingestion.Source.from_referrer("https://" <> ref) |
| end |
| end |
|
|