| defmodule Plausible.Google.GA4.API do |
| @moduledoc """ |
| API for Google Analytics 4. |
| """ |
|
|
| alias Plausible.Google |
| alias Plausible.Google.GA4 |
|
|
| require Logger |
|
|
| @type import_auth :: { |
| access_token :: String.t(), |
| refresh_token :: String.t(), |
| expires_at :: String.t() |
| } |
|
|
| @per_page 200_000 |
| @backoff_factor :timer.seconds(10) |
| @max_attempts 5 |
|
|
| def list_properties(access_token) do |
| case GA4.HTTP.list_accounts_for_user(access_token) do |
| {:ok, %{"accountSummaries" => accounts}} -> |
| accounts = |
| accounts |
| |> Enum.filter(& &1["propertySummaries"]) |
| |> Enum.map(fn account -> |
| %{"account" => account_id, "displayName" => account_name} = account |
|
|
| {"#{account_name} (#{account_id})", |
| Enum.map(account["propertySummaries"], fn property -> |
| %{"displayName" => property_name, "property" => property_id} = property |
|
|
| {"#{property_name} (#{property_id})", property_id} |
| end)} |
| end) |
|
|
| {:ok, accounts} |
|
|
| {:ok, _} -> |
| {:ok, []} |
|
|
| {:error, cause} -> |
| {:error, cause} |
| end |
| end |
|
|
| def get_property(access_token, lookup_property) do |
| case GA4.HTTP.get_property(access_token, lookup_property) do |
| {:ok, property} -> |
| %{"displayName" => property_name, "name" => property_id, "account" => account_id} = |
| property |
|
|
| {:ok, |
| %{ |
| id: property_id, |
| name: "#{property_name} (#{property_id})", |
| account_id: account_id |
| }} |
|
|
| {:error, cause} -> |
| {:error, cause} |
| end |
| end |
|
|
| def get_analytics_start_date(access_token, property) do |
| GA4.HTTP.get_analytics_start_date(access_token, property) |
| end |
|
|
| def get_analytics_end_date(access_token, property) do |
| GA4.HTTP.get_analytics_end_date(access_token, property) |
| end |
|
|
| def import_analytics(date_range, property, auth, opts) do |
| persist_fn = Keyword.fetch!(opts, :persist_fn) |
| fetch_opts = Keyword.get(opts, :fetch_opts, []) |
| resume_opts = Keyword.get(opts, :resume_opts, []) |
|
|
| Logger.debug( |
| "[#{inspect(__MODULE__)}:#{property}] Starting import from #{date_range.first} to #{date_range.last}" |
| ) |
|
|
| with {:ok, access_token} <- Google.API.maybe_refresh_token(auth) do |
| do_import_analytics(date_range, property, access_token, persist_fn, fetch_opts, resume_opts) |
| end |
| end |
|
|
| defp do_import_analytics( |
| date_range, |
| property, |
| access_token, |
| persist_fn, |
| fetch_opts, |
| [] = _resume_opts |
| ) do |
| Enum.reduce_while(GA4.ReportRequest.full_report(), :ok, fn report_request, :ok -> |
| Logger.debug( |
| "[#{inspect(__MODULE__)}:#{property}] Starting to import #{report_request.dataset}" |
| ) |
|
|
| report_request = prepare_request(report_request, date_range, property, access_token) |
|
|
| case fetch_and_persist(report_request, persist_fn: persist_fn, fetch_opts: fetch_opts) do |
| :ok -> {:cont, :ok} |
| {:error, _} = error -> {:halt, error} |
| end |
| end) |
| end |
|
|
| defp do_import_analytics( |
| date_range, |
| property, |
| access_token, |
| persist_fn, |
| fetch_opts, |
| resume_opts |
| ) do |
| dataset = Keyword.fetch!(resume_opts, :dataset) |
| offset = Keyword.fetch!(resume_opts, :offset) |
|
|
| GA4.ReportRequest.full_report() |
| |> Enum.drop_while(&(&1.dataset != dataset)) |
| |> Enum.reduce_while(:ok, fn report_request, :ok -> |
| Logger.debug( |
| "[#{inspect(__MODULE__)}:#{property}] Starting to import #{report_request.dataset}" |
| ) |
|
|
| request_offset = |
| if report_request.dataset == dataset do |
| offset |
| else |
| 0 |
| end |
|
|
| report_request = |
| report_request |
| |> prepare_request(date_range, property, access_token) |
| |> Map.put(:offset, request_offset) |
|
|
| case fetch_and_persist(report_request, persist_fn: persist_fn, fetch_opts: fetch_opts) do |
| :ok -> {:cont, :ok} |
| {:error, _} = error -> {:halt, error} |
| end |
| end) |
| end |
|
|
| @spec fetch_and_persist(GA4.ReportRequest.t(), Keyword.t()) :: |
| :ok | {:error, term()} |
| def fetch_and_persist(%GA4.ReportRequest{} = report_request, opts \\ []) do |
| persist_fn = Keyword.fetch!(opts, :persist_fn) |
| attempt = Keyword.get(opts, :attempt, 1) |
| fetch_opts = Keyword.get(opts, :fetch_opts, []) |
| max_attempts = Keyword.get(fetch_opts, :max_attempts, @max_attempts) |
| sleep_time = Keyword.get(fetch_opts, :sleep_time, @backoff_factor) |
|
|
| case GA4.HTTP.get_report(report_request) do |
| {:ok, {_, 0}} -> |
| Logger.debug( |
| "[#{inspect(__MODULE__)}:#{report_request.property}] Fetched empty response for #{report_request.dataset}" |
| ) |
|
|
| :ok |
|
|
| {:ok, {rows, row_count}} -> |
| Logger.debug( |
| "[#{inspect(__MODULE__)}:#{report_request.property}] Fetched #{length(rows)} rows of total #{row_count} with offset #{report_request.offset} for #{report_request.dataset}" |
| ) |
|
|
| :ok = persist_fn.(report_request.dataset, rows) |
|
|
| Logger.debug( |
| "[#{inspect(__MODULE__)}:#{report_request.property}] Persisted #{length(rows)} for #{report_request.dataset}" |
| ) |
|
|
| if report_request.offset + @per_page < row_count do |
| fetch_and_persist( |
| %GA4.ReportRequest{report_request | offset: report_request.offset + @per_page}, |
| opts |
| ) |
| else |
| :ok |
| end |
|
|
| {:error, {:rate_limit_exceeded, details}} -> |
| {:error, {:rate_limit_exceeded, details}} |
|
|
| {:error, cause} -> |
| if attempt >= max_attempts do |
| Logger.debug( |
| "[#{inspect(__MODULE__)}:#{report_request.property}] Request failed for #{report_request.dataset}. Terminating." |
| ) |
|
|
| {:error, cause} |
| else |
| Logger.debug( |
| "[#{inspect(__MODULE__)}:#{report_request.property}] Request failed for #{report_request.dataset}. Will retry." |
| ) |
|
|
| Process.sleep(attempt * sleep_time) |
| fetch_and_persist(report_request, Keyword.merge(opts, attempt: attempt + 1)) |
| end |
| end |
| end |
|
|
| defp prepare_request(%GA4.ReportRequest{} = report_request, date_range, property, access_token) do |
| %GA4.ReportRequest{ |
| report_request |
| | date_range: date_range, |
| property: property, |
| access_token: access_token, |
| offset: 0, |
| limit: @per_page |
| } |
| end |
| end |
|
|