defmodule Plausible.Google.GA4.HTTP do @moduledoc """ HTTP client implementation for Google Analytics 4 API. """ use Plausible alias Plausible.HTTPClient require Logger @spec get_report(Plausible.Google.GA4.ReportRequest.t()) :: {:ok, {[map()], non_neg_integer()}} | {:error, any()} def get_report(%Plausible.Google.GA4.ReportRequest{} = report_request) do params = %{ requests: [ %{ property: report_request.property, dateRanges: [ %{ startDate: report_request.date_range.first, endDate: report_request.date_range.last } ], dimensions: Enum.map(report_request.dimensions, &%{name: &1}), metrics: Enum.map(report_request.metrics, &build_metric/1), orderBys: [ %{ dimension: %{ dimensionName: "date", orderType: "ALPHANUMERIC" }, desc: true } ], dimensionFilter: report_request.dimension_filter, limit: report_request.limit, offset: report_request.offset } ] } url = "#{reporting_api_url()}/v1beta/#{report_request.property}:batchRunReports" response = HTTPClient.impl().post( url, [{"Authorization", "Bearer #{report_request.access_token}"}], params, receive_timeout: 80_000 ) with {:ok, %{body: body}} <- response, {:ok, report} <- parse_report_from_response(body), row_count <- Map.get(report, "rowCount", 0), {:ok, report} <- convert_to_maps(report) do {:ok, {report, row_count}} else {:error, %{reason: %{status: 429, body: body}} = error} -> log_ce_error("retrieving report for #{report_request.dataset}", error) Logger.debug( "[#{inspect(__MODULE__)}:#{report_request.property}] Request failed for #{report_request.dataset} due to exceeding rate limit." ) Sentry.Context.set_extra_context(%{ga_response: %{body: body, status: 429}}) {:error, {:rate_limit_exceeded, dataset: report_request.dataset, offset: report_request.offset}} {:error, %{reason: %{status: status, body: body}} = error} when status >= 500 -> log_ce_error("retrieving report for #{report_request.dataset}", error) Logger.warning( "[#{inspect(__MODULE__)}:#{report_request.property}] Request failed for #{report_request.dataset} with code #{status}: #{inspect(body)}" ) Sentry.Context.set_extra_context(%{ga_response: %{body: body, status: status}}) {:error, {:server_failed, dataset: report_request.dataset, offset: report_request.offset}} {:error, %{reason: %{status: status, body: body}} = error} -> log_ce_error("retrieving report for #{report_request.dataset}", error) Logger.warning( "[#{inspect(__MODULE__)}:#{report_request.property}] Request failed for #{report_request.dataset} with code #{status}: #{inspect(body)}" ) Sentry.Context.set_extra_context(%{ga_response: %{body: body, status: status}}) {:error, :request_failed} {:error, reason} -> log_ce_error("retrieving report for #{report_request.dataset}", reason) Logger.warning( "[#{inspect(__MODULE__)}:#{report_request.property}] Request failed for #{report_request.dataset}: #{inspect(reason)}" ) Sentry.Context.set_extra_context(%{ga_response: %{body: inspect(reason), status: 0}}) {:error, {:socket_failed, dataset: report_request.dataset, offset: report_request.offset}} end end defp build_metric(expression) do case String.split(expression, " = ") do [name, expression] -> %{ name: name, expression: expression } [name] -> %{name: name} end end defp parse_report_from_response(%{"reports" => [report | _]}) do {:ok, report} end defp parse_report_from_response(body) do Sentry.Context.set_extra_context(%{google_analytics4_response: body}) Logger.error( "Google Analytics 4: Failed to find report in response. Reason: #{inspect(body)}" ) {:error, {:invalid_response, body}} end defp convert_to_maps(%{ "rows" => rows, "dimensionHeaders" => dimension_headers, "metricHeaders" => metric_headers }) when is_list(rows) do dimension_headers = Enum.map(dimension_headers, & &1["name"]) metric_headers = Enum.map(metric_headers, & &1["name"]) report = Enum.map(rows, fn %{"dimensionValues" => dimensions, "metricValues" => metrics} -> dimension_values = Enum.map(dimensions, & &1["value"]) metric_values = Enum.map(metrics, & &1["value"]) metrics = Enum.zip(metric_headers, metric_values) dimensions = Enum.zip(dimension_headers, dimension_values) %{metrics: Map.new(metrics), dimensions: Map.new(dimensions)} end) {:ok, report} end defp convert_to_maps(%{"dimensionHeaders" => _, "metricHeaders" => _}) do {:ok, []} end defp convert_to_maps(response) do Logger.error( "Google Analytics 4: Failed to read report in response. Reason: #{inspect(response)}" ) Sentry.Context.set_extra_context(%{google_analytics4_response: response}) {:error, {:invalid_response, response}} end def list_accounts_for_user(access_token) do url = "#{admin_api_url()}/v1beta/accountSummaries?pageSize=200" headers = [{"Authorization", "Bearer #{access_token}"}] case HTTPClient.impl().get(url, headers) do {:ok, %Finch.Response{body: body, status: 200}} -> {:ok, body} {:error, %HTTPClient.Non200Error{reason: %{status: 429}} = error} -> log_ce_error("listing accounts for user", error) {:error, :rate_limit_exceeded} {:error, %HTTPClient.Non200Error{} = error} when error.reason.status in [401, 403] -> log_ce_error("listing accounts for user", error) {:error, authentication_failed(error)} {:error, %{reason: :timeout} = error} -> log_ce_error("listing accounts for user", error) {:error, :timeout} {:error, error} -> log_ce_error("listing accounts for user", error) Sentry.capture_message("Error listing GA4 accounts for user", extra: %{error: error}) {:error, :unknown} end end def get_property(access_token, property) do url = "#{admin_api_url()}/v1beta/#{property}" headers = [{"Authorization", "Bearer #{access_token}"}] case HTTPClient.impl().get(url, headers) do {:ok, %Finch.Response{body: body, status: 200}} -> {:ok, body} {:error, %HTTPClient.Non200Error{reason: %{status: 429}} = error} -> log_ce_error("retrieving property #{property}", error) {:error, :rate_limit_exceeded} {:error, %HTTPClient.Non200Error{} = error} when error.reason.status in [401, 403] -> log_ce_error("retrieving property #{property}", error) {:error, authentication_failed(error)} {:error, %HTTPClient.Non200Error{} = error} when error.reason.status in [404] -> log_ce_error("retrieving property #{property}", error) {:error, :not_found} {:error, %{reason: :timeout} = error} -> log_ce_error("retrieving property #{property}", error) {:error, :timeout} {:error, error} -> log_ce_error("retrieving property #{property}", error) Sentry.capture_message("Error retrieving GA4 property #{property}", extra: %{error: error} ) {:error, :unknown} end end @earliest_valid_date "2015-08-14" def get_analytics_start_date(access_token, property) do get_analytics_boundary_date(access_token, property, :start) end def get_analytics_end_date(access_token, property) do get_analytics_boundary_date(access_token, property, :end) end defp get_analytics_boundary_date(access_token, property, edge) do descending? = edge == :end params = %{ requests: [ %{ property: "#{property}", dateRanges: [ %{startDate: @earliest_valid_date, endDate: Date.to_iso8601(Date.utc_today())} ], dimensions: [%{name: "date"}], metrics: [%{name: "screenPageViews"}], orderBys: [ %{dimension: %{dimensionName: "date", orderType: "ALPHANUMERIC"}, desc: descending?} ], limit: 1 } ] } url = "#{reporting_api_url()}/v1beta/#{property}:batchRunReports" headers = [{"Authorization", "Bearer #{access_token}"}] case HTTPClient.impl().post(url, headers, params) do {:ok, %Finch.Response{body: body, status: 200}} -> report = List.first(body["reports"]) date = case report["rows"] do [%{"dimensionValues" => [%{"value" => date_str}]}] -> Plausible.Times.parse!(date_str, "%Y%m%d", :strftime) |> NaiveDateTime.to_date() _ -> nil end {:ok, date} {:error, %HTTPClient.Non200Error{reason: %{status: 429}} = error} -> log_ce_error("retrieving #{edge} date", error) {:error, :rate_limit_exceeded} {:error, %HTTPClient.Non200Error{} = error} when error.reason.status in [401, 403] -> log_ce_error("retrieving #{edge} date", error) {:error, authentication_failed(error)} {:error, %{reason: :timeout} = error} -> log_ce_error("retrieving #{edge} date", error) {:error, :timeout} {:error, error} -> log_ce_error("retrieving #{edge} date", error) Sentry.capture_message("Error retrieving GA4 #{edge} date", extra: %{error: error} ) {:error, :unknown} end end defp reporting_api_url, do: "https://analyticsdata.googleapis.com" defp admin_api_url, do: "https://analyticsadmin.googleapis.com" @spec authentication_failed(HTTPClient.Non200Error.t()) :: {:authentication_failed, String.t() | nil} defp authentication_failed(error) do message = case error.reason.body do %{"error" => %{"message" => message}} when is_binary(message) -> message _ -> nil end {:authentication_failed, message} end @spec log_ce_error(String.t(), any) :: :ok defp log_ce_error(action, error) on_ce do defp log_ce_error(action, error) do Logger.error("Google Analytics 4: Failed when #{action}. Reason: #{inspect(error)}") end end on_ee do defp log_ce_error(_action, _error), do: :ok end end