Remove Universal Analytics import logic (#4312)

This commit is contained in:
Adrian Gruntkowski 2024-07-05 13:58:42 +02:00 committed by GitHub
parent 2f2602e316
commit 35596e8692
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 443 additions and 865404 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +0,0 @@
{
"reports": [
{
"columnHeader": {
"dimensions": [
"ga:date"
],
"metricHeader": {
"metricHeaderEntries": [
{
"name": "ga:pageviews",
"type": "INTEGER"
}
]
}
},
"data": {
"isDataGolden": true,
"rowCount": 849,
"rows": [
{
"dimensions": [
"20200421"
],
"metrics": [
{
"values": [
"37"
]
}
]
}
]
},
"nextPageToken": "1"
}
]
}

View File

@ -1,67 +0,0 @@
{
"kind": "analytics#profiles",
"username": "email@provider.test",
"totalResults": 2,
"startIndex": 1,
"itemsPerPage": 1000,
"items": [
{
"id": "57238190",
"kind": "analytics#profile",
"selfLink": "https://www.googleapis.com/analytics/v3/management/accounts/61782930/webproperties/UA-1625528-17/profiles/57238190",
"accountId": "61782930",
"webPropertyId": "UA-61782930-17",
"internalWebPropertyId": "53451925",
"name": "one.test",
"currency": "USD",
"timezone": "Europe/London",
"websiteUrl": "http://one.test/",
"type": "WEB",
"permissions": {
"effective": [
"READ_AND_ANALYZE"
]
},
"created": "2011-12-25T15:16:53.069Z",
"updated": "2012-07-26T09:45:03.811Z",
"eCommerceTracking": false,
"parentLink": {
"type": "analytics#webproperty",
"href": "https://www.googleapis.com/analytics/v3/management/accounts/61782930/webproperties/UA-1625528-17"
},
"childLink": {
"type": "analytics#goals",
"href": "https://www.googleapis.com/analytics/v3/management/accounts/61782930/webproperties/UA-1625528-17/profiles/57238190/goals"
}
},
{
"id": "54460083",
"kind": "analytics#profile",
"selfLink": "https://www.googleapis.com/analytics/v3/management/accounts/61782930/webproperties/UA-1625528-18/profiles/54460083",
"accountId": "61782930",
"webPropertyId": "UA-61782930-18",
"internalWebPropertyId": "53597744",
"name": "two.test",
"currency": "USD",
"timezone": "Europe/London",
"websiteUrl": "http://two.test/",
"type": "WEB",
"permissions": {
"effective": [
"READ_AND_ANALYZE"
]
},
"created": "2011-12-31T19:14:11.434Z",
"updated": "2012-04-03T18:34:11.475Z",
"eCommerceTracking": false,
"parentLink": {
"type": "analytics#webproperty",
"href": "https://www.googleapis.com/analytics/v3/management/accounts/61782930/webproperties/UA-1625528-18"
},
"childLink": {
"type": "analytics#goals",
"href": "https://www.googleapis.com/analytics/v3/management/accounts/61782930/webproperties/UA-1625528-18/profiles/54460083/goals"
}
}
]
}

View File

@ -1,25 +0,0 @@
{
"reports": [
{
"columnHeader": {
"dimensions": [
"ga:date",
"ga:source",
"ga:medium",
"ga:campaign",
"ga:adContent",
"ga:keyword"
],
"metricHeader": {
"metricHeaderEntries": [
{ "name": "ga:users", "type": "INTEGER" },
{ "name": "ga:sessions", "type": "INTEGER" },
{ "name": "ga:bounces", "type": "INTEGER" },
{ "name": "ga:sessionDuration", "type": "TIME" }
]
}
},
"data": { "isDataGolden": true }
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,38 +0,0 @@
{
"reports": [
{
"columnHeader": {
"dimensions": [
"ga:date"
],
"metricHeader": {
"metricHeaderEntries": [
{
"name": "ga:pageviews",
"type": "INTEGER"
}
]
}
},
"data": {
"isDataGolden": true,
"rowCount": 849,
"rows": [
{
"dimensions": [
"20120118"
],
"metrics": [
{
"values": [
"37"
]
}
]
}
]
},
"nextPageToken": "1"
}
]
}

View File

@ -1,38 +0,0 @@
{
"reports": [
{
"columnHeader": {
"dimensions": [
"ga:date"
],
"metricHeader": {
"metricHeaderEntries": [
{
"name": "ga:pageviews",
"type": "INTEGER"
}
]
}
},
"data": {
"isDataGolden": true,
"rowCount": 849,
"rows": [
{
"dimensions": [
"20170118"
],
"metrics": [
{
"values": [
"37"
]
}
]
}
]
},
"nextPageToken": "1"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -31,32 +31,20 @@ defmodule Plausible.Google.API do
HTTP.fetch_access_token!(code)
end
def list_properties_and_views(access_token) do
def list_properties(access_token) do
Plausible.Google.GA4.API.list_properties(access_token)
end
def get_property_or_view(access_token, property_or_view) do
if property?(property_or_view) do
Plausible.Google.GA4.API.get_property(access_token, property_or_view)
else
Plausible.Google.UA.API.get_view(access_token, property_or_view)
end
def get_property(access_token, property) do
Plausible.Google.GA4.API.get_property(access_token, property)
end
def get_analytics_start_date(access_token, property_or_view) do
if property?(property_or_view) do
Plausible.Google.GA4.API.get_analytics_start_date(access_token, property_or_view)
else
Plausible.Google.UA.API.get_analytics_start_date(access_token, property_or_view)
end
def get_analytics_start_date(access_token, property) do
Plausible.Google.GA4.API.get_analytics_start_date(access_token, property)
end
def get_analytics_end_date(access_token, property_or_view) do
if property?(property_or_view) do
Plausible.Google.GA4.API.get_analytics_end_date(access_token, property_or_view)
else
Plausible.Google.UA.API.get_analytics_end_date(access_token, property_or_view)
end
def get_analytics_end_date(access_token, property) do
Plausible.Google.GA4.API.get_analytics_end_date(access_token, property)
end
def fetch_verified_properties(auth) do

View File

@ -1,155 +0,0 @@
defmodule Plausible.Google.UA.API do
@moduledoc """
API for Universal Analytics
"""
alias Plausible.Google
alias Plausible.Google.UA
@type google_analytics_view() :: {view_name :: String.t(), view_id :: String.t()}
@type import_auth :: {
access_token :: String.t(),
refresh_token :: String.t(),
expires_at :: String.t()
}
@per_page 100_000
@backoff_factor :timer.seconds(10)
@max_attempts 5
@spec list_views(access_token :: String.t()) ::
{:ok, %{(hostname :: String.t()) => [google_analytics_view()]}} | {:error, term()}
@doc """
Lists Google Analytics views grouped by hostname.
"""
def list_views(access_token) do
case UA.HTTP.list_views_for_user(access_token) do
{:ok, %{"items" => views}} ->
views =
views
|> Enum.group_by(&view_hostname/1, &view_names/1)
|> Enum.sort_by(fn {key, _} -> key end)
{:ok, views}
error ->
error
end
end
@spec get_view(access_token :: String.t(), lookup_id :: String.t()) ::
{:ok, google_analytics_view()} | {:ok, nil} | {:error, term()}
@doc """
Returns a single Google Analytics view if the user has access to it.
"""
def get_view(access_token, lookup_id) do
with {:ok, views} <- list_views(access_token) do
views =
views
|> Enum.map(&elem(&1, 1))
|> List.flatten()
case Enum.find(views, fn {_name, id} -> id == lookup_id end) do
{view_name, view_id} ->
{:ok, %{id: view_id, name: "#{view_name}"}}
nil ->
{:error, :not_found}
end
end
end
def get_analytics_start_date(access_token, view_id) do
UA.HTTP.get_analytics_start_date(access_token, view_id)
end
def get_analytics_end_date(access_token, view_id) do
UA.HTTP.get_analytics_end_date(access_token, view_id)
end
@spec import_analytics(Date.Range.t(), String.t(), import_auth(), (String.t(), [map()] -> :ok)) ::
:ok | {:error, term()}
@doc """
Imports stats from a Google Analytics UA view to a Plausible site.
This function fetches Google Analytics reports in batches of #{@per_page} per
request. The batches are then passed to persist callback.
Requests to Google Analytics can fail, and are retried at most
#{@max_attempts} times with an exponential backoff. Returns `:ok` when
importing has finished or `{:error, term()}` when a request to GA failed too
many times.
Useful links:
- [Feature documentation](https://plausible.io/docs/google-analytics-import)
- [GA API reference](https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/reports/batchGet#ReportRequest)
- [GA Dimensions reference](https://ga-dev-tools.web.app/dimensions-metrics-explorer)
"""
def import_analytics(date_range, view_id, auth, persist_fn) do
with {:ok, access_token} <- Google.API.maybe_refresh_token(auth) do
do_import_analytics(date_range, view_id, access_token, persist_fn)
end
end
defp do_import_analytics(date_range, view_id, access_token, persist_fn) do
Enum.reduce_while(UA.ReportRequest.full_report(), :ok, fn report_request, :ok ->
report_request = %UA.ReportRequest{
report_request
| date_range: date_range,
view_id: view_id,
access_token: access_token,
page_token: nil,
page_size: @per_page
}
case fetch_and_persist(report_request, persist_fn: persist_fn) do
:ok -> {:cont, :ok}
{:error, _} = error -> {:halt, error}
end
end)
end
@spec fetch_and_persist(UA.ReportRequest.t(), Keyword.t()) ::
:ok | {:error, term()}
def fetch_and_persist(%UA.ReportRequest{} = report_request, opts \\ []) do
persist_fn = Keyword.fetch!(opts, :persist_fn)
attempt = Keyword.get(opts, :attempt, 1)
sleep_time = Keyword.get(opts, :sleep_time, @backoff_factor)
case UA.HTTP.get_report(report_request) do
{:ok, {rows, next_page_token}} ->
:ok = persist_fn.(report_request.dataset, rows)
if next_page_token do
fetch_and_persist(
%UA.ReportRequest{report_request | page_token: next_page_token},
opts
)
else
:ok
end
{:error, cause} ->
if attempt >= @max_attempts do
{:error, cause}
else
Process.sleep(attempt * sleep_time)
fetch_and_persist(report_request, Keyword.merge(opts, attempt: attempt + 1))
end
end
end
defp view_hostname(view) do
case view do
%{"websiteUrl" => url} when is_binary(url) -> url |> URI.parse() |> Map.get(:host)
_any -> "Others"
end
end
defp view_names(%{"name" => name, "id" => id}) do
{"#{id} - #{name}", id}
end
end

View File

@ -1,199 +0,0 @@
defmodule Plausible.Google.UA.HTTP do
@moduledoc """
HTTP client implementation for Universal Analytics API.
"""
require Logger
alias Plausible.HTTPClient
@spec get_report(Plausible.Google.UA.ReportRequest.t()) ::
{:ok, {[map()], String.t() | nil}} | {:error, any()}
def get_report(%Plausible.Google.UA.ReportRequest{} = report_request) do
params = %{
reportRequests: [
%{
viewId: report_request.view_id,
dateRanges: [
%{
startDate: report_request.date_range.first,
endDate: report_request.date_range.last
}
],
dimensions: Enum.map(report_request.dimensions, &%{name: &1, histogramBuckets: []}),
metrics: Enum.map(report_request.metrics, &%{expression: &1}),
hideTotals: true,
hideValueRanges: true,
orderBys: [%{fieldName: "ga:date", sortOrder: "DESCENDING"}],
pageSize: report_request.page_size,
pageToken: report_request.page_token
}
]
}
response =
HTTPClient.impl().post(
"#{reporting_api_url()}/v4/reports:batchGet",
[{"Authorization", "Bearer #{report_request.access_token}"}],
params,
receive_timeout: 60_000
)
with {:ok, %{body: body}} <- response,
{:ok, report} <- parse_report_from_response(body),
token <- Map.get(report, "nextPageToken"),
{:ok, report} <- convert_to_maps(report) do
{:ok, {report, token}}
else
{:error, %{reason: %{status: status, body: body}}} ->
Logger.debug(
"[#{inspect(__MODULE__)}:#{report_request.view_id}] 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} ->
Logger.debug(
"[#{inspect(__MODULE__)}:#{report_request.view_id}] Request failed for #{report_request.dataset}: #{inspect(reason)}"
)
{:error, :request_failed}
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(%{universal_analytics_response: body})
Logger.error(
"Universal Analytics: Failed to find report in response. Reason: #{inspect(body)}"
)
{:error, {:invalid_response, body}}
end
defp convert_to_maps(%{
"data" => %{} = data,
"columnHeader" => %{
"dimensions" => dimension_headers,
"metricHeader" => %{"metricHeaderEntries" => metric_headers}
}
}) do
metric_headers = Enum.map(metric_headers, & &1["name"])
rows = Map.get(data, "rows", [])
report =
Enum.map(rows, fn %{"dimensions" => dimensions, "metrics" => [%{"values" => metrics}]} ->
metrics = Enum.zip(metric_headers, metrics)
dimensions = Enum.zip(dimension_headers, dimensions)
%{metrics: Map.new(metrics), dimensions: Map.new(dimensions)}
end)
{:ok, report}
end
defp convert_to_maps(response) do
Logger.error(
"Universal Analytics: Failed to read report in response. Reason: #{inspect(response)}"
)
Sentry.Context.set_extra_context(%{universal_analytics_response: response})
{:error, {:invalid_response, response}}
end
def list_views_for_user(access_token) do
url = "#{api_url()}/analytics/v3/management/accounts/~all/webproperties/~all/profiles"
headers = [{"Authorization", "Bearer #{access_token}"}]
case HTTPClient.impl().get(url, headers) do
{:ok, %Finch.Response{body: body, status: 200}} ->
{:ok, body}
{:error, %HTTPClient.Non200Error{} = error} when error.reason.status in [401, 403] ->
{:error, :authentication_failed}
{:error, %{reason: :timeout}} ->
{:error, :timeout}
{:error, error} ->
Sentry.capture_message("Error listing UA views for user", extra: %{error: error})
{:error, :unknown}
end
end
@earliest_valid_date "2005-01-01"
def get_analytics_start_date(access_token, view_id) do
get_analytics_boundary_date(access_token, view_id, :start)
end
def get_analytics_end_date(access_token, view_id) do
get_analytics_boundary_date(access_token, view_id, :end)
end
defp get_analytics_boundary_date(access_token, view_id, edge) do
sort_order =
if edge == :start do
"ASCENDING"
else
"DESCENDING"
end
params = %{
reportRequests: [
%{
viewId: view_id,
dateRanges: [
%{startDate: @earliest_valid_date, endDate: Date.to_iso8601(Timex.today())}
],
dimensions: [%{name: "ga:date", histogramBuckets: []}],
metrics: [%{expression: "ga:pageviews"}],
hideTotals: true,
hideValueRanges: true,
orderBys: [%{fieldName: "ga:date", sortOrder: sort_order}],
pageSize: 1
}
]
}
url = "#{reporting_api_url()}/v4/reports:batchGet"
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["data"]["rows"] do
[%{"dimensions" => [date_str]}] ->
Timex.parse!(date_str, "%Y%m%d", :strftime) |> NaiveDateTime.to_date()
_ ->
nil
end
{:ok, date}
{:error, %HTTPClient.Non200Error{} = error} when error.reason.status in [401, 403] ->
{:error, :authentication_failed}
{:error, %{reason: :timeout}} ->
{:error, :timeout}
{:error, error} ->
Sentry.capture_message("Error retrieving UA #{edge} date",
extra: %{error: error}
)
{:error, :unknown}
end
end
defp config, do: Application.get_env(:plausible, :google)
defp reporting_api_url, do: Keyword.fetch!(config(), :reporting_api_url)
defp api_url, do: Keyword.fetch!(config(), :api_url)
end

View File

@ -1,84 +0,0 @@
defmodule Plausible.Google.UA.ReportRequest do
@moduledoc """
Report request struct for Universal Analytics API
"""
defstruct [
:dataset,
:dimensions,
:metrics,
:date_range,
:view_id,
:access_token,
:page_token,
:page_size
]
@type t() :: %__MODULE__{
dataset: String.t(),
dimensions: [String.t()],
metrics: [String.t()],
date_range: Date.Range.t(),
view_id: term(),
access_token: String.t(),
page_token: String.t() | nil,
page_size: non_neg_integer()
}
def full_report do
[
%__MODULE__{
dataset: "imported_visitors",
dimensions: ["ga:date"],
metrics: ["ga:users", "ga:pageviews", "ga:bounces", "ga:sessions", "ga:sessionDuration"]
},
%__MODULE__{
dataset: "imported_sources",
dimensions: [
"ga:date",
"ga:source",
"ga:medium",
"ga:campaign",
"ga:adContent",
"ga:keyword"
],
metrics: ["ga:users", "ga:sessions", "ga:bounces", "ga:sessionDuration"]
},
%__MODULE__{
dataset: "imported_pages",
dimensions: ["ga:date", "ga:hostname", "ga:pagePath"],
metrics: ["ga:users", "ga:pageviews", "ga:exits", "ga:timeOnPage"]
},
%__MODULE__{
dataset: "imported_entry_pages",
dimensions: ["ga:date", "ga:landingPagePath"],
metrics: ["ga:users", "ga:entrances", "ga:sessionDuration", "ga:bounces"]
},
%__MODULE__{
dataset: "imported_exit_pages",
dimensions: ["ga:date", "ga:exitPagePath"],
metrics: ["ga:users", "ga:exits"]
},
%__MODULE__{
dataset: "imported_locations",
dimensions: ["ga:date", "ga:countryIsoCode", "ga:regionIsoCode", "ga:city"],
metrics: ["ga:users", "ga:sessions", "ga:bounces", "ga:sessionDuration"]
},
%__MODULE__{
dataset: "imported_devices",
dimensions: ["ga:date", "ga:deviceCategory"],
metrics: ["ga:users", "ga:sessions", "ga:bounces", "ga:sessionDuration"]
},
%__MODULE__{
dataset: "imported_browsers",
dimensions: ["ga:date", "ga:browser"],
metrics: ["ga:users", "ga:sessions", "ga:bounces", "ga:sessionDuration"]
},
%__MODULE__{
dataset: "imported_operating_systems",
dimensions: ["ga:date", "ga:operatingSystem"],
metrics: ["ga:users", "ga:sessions", "ga:bounces", "ga:sessionDuration"]
}
]
end
end

View File

@ -2,12 +2,7 @@ defmodule Plausible.Imported do
@moduledoc """
Context for managing site statistics imports.
Currently following importers are implemented:
* `Plausible.Imported.UniversalAnalytics` - existing mechanism, for legacy Google
analytics formerly known as "Google Analytics"
* `Plausible.Imported.NoopImporter` - importer stub, used mainly for testing purposes
* `Plausible.Imported.CSVImporter` - CSV importer from S3
For list of currently supported import sources see `Plausible.Imported.ImportSources`.
For more information on implementing importers, see `Plausible.Imported.Importer`.
"""

View File

@ -1,12 +1,13 @@
defmodule Plausible.Imported.UniversalAnalytics do
@moduledoc """
Import implementation for Universal Analytics.
NOTE: As importing from UA is no longer supported, this module
is only used to support rendering existing imports.
"""
use Plausible.Imported.Importer
@missing_values ["(none)", "(not set)", "(not provided)", "(other)"]
@impl true
def name(), do: :universal_analytics
@ -17,234 +18,12 @@ defmodule Plausible.Imported.UniversalAnalytics do
def email_template(), do: "google_analytics_import.html"
@impl true
def parse_args(
%{"view_id" => view_id, "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")
}
[
view_id: view_id,
date_range: date_range,
auth: auth
]
def parse_args(_args) do
raise "Importing data not supported"
end
@doc """
Imports stats from a Google Analytics UA view to a Plausible site.
This function fetches Google Analytics 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)
view_id = Keyword.fetch!(opts, :view_id)
auth = Keyword.fetch!(opts, :auth)
{:ok, buffer} = Plausible.Imported.Buffer.start_link()
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
try do
Plausible.Google.UA.API.import_analytics(date_range, view_id, auth, persist_fn)
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, "ga: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 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!("ga:users") |> parse_number(),
pageviews: row.metrics |> Map.fetch!("ga:pageviews") |> parse_number(),
bounces: row.metrics |> Map.fetch!("ga:bounces") |> parse_number(),
visits: row.metrics |> Map.fetch!("ga:sessions") |> parse_number(),
visit_duration: row.metrics |> Map.fetch!("ga:sessionDuration") |> 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!("ga:source") |> parse_referrer(),
utm_medium: row.dimensions |> Map.fetch!("ga:medium") |> default_if_missing(),
utm_campaign: row.dimensions |> Map.fetch!("ga:campaign") |> default_if_missing(),
utm_content: row.dimensions |> Map.fetch!("ga:adContent") |> default_if_missing(),
utm_term: row.dimensions |> Map.fetch!("ga:keyword") |> default_if_missing(),
visitors: row.metrics |> Map.fetch!("ga:users") |> parse_number(),
visits: row.metrics |> Map.fetch!("ga:sessions") |> parse_number(),
bounces: row.metrics |> Map.fetch!("ga:bounces") |> parse_number(),
visit_duration: row.metrics |> Map.fetch!("ga:sessionDuration") |> 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!("ga:hostname") |> String.replace_prefix("www.", ""),
page: row.dimensions |> Map.fetch!("ga:pagePath") |> URI.parse() |> Map.get(:path),
visitors: row.metrics |> Map.fetch!("ga:users") |> parse_number(),
pageviews: row.metrics |> Map.fetch!("ga:pageviews") |> parse_number(),
exits: row.metrics |> Map.fetch!("ga:exits") |> parse_number(),
time_on_page: row.metrics |> Map.fetch!("ga:timeOnPage") |> 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!("ga:landingPagePath"),
visitors: row.metrics |> Map.fetch!("ga:users") |> parse_number(),
entrances: row.metrics |> Map.fetch!("ga:entrances") |> parse_number(),
visit_duration: row.metrics |> Map.fetch!("ga:sessionDuration") |> parse_number(),
bounces: row.metrics |> Map.fetch!("ga:bounces") |> parse_number()
}
end
defp new_from_report(site_id, import_id, "imported_exit_pages", row) do
%{
site_id: site_id,
import_id: import_id,
date: get_date(row),
exit_page: Map.fetch!(row.dimensions, "ga:exitPagePath"),
visitors: row.metrics |> Map.fetch!("ga:users") |> parse_number(),
exits: row.metrics |> Map.fetch!("ga:exits") |> parse_number()
}
end
defp new_from_report(site_id, import_id, "imported_locations", row) do
country_code = row.dimensions |> Map.fetch!("ga:countryIsoCode") |> default_if_missing("")
city_name = row.dimensions |> Map.fetch!("ga: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!("ga:regionIsoCode") |> default_if_missing(""),
city: city_data && city_data.id,
visitors: row.metrics |> Map.fetch!("ga:users") |> parse_number(),
visits: row.metrics |> Map.fetch!("ga:sessions") |> parse_number(),
bounces: row.metrics |> Map.fetch!("ga:bounces") |> parse_number(),
visit_duration: row.metrics |> Map.fetch!("ga:sessionDuration") |> 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!("ga:deviceCategory") |> String.capitalize(),
visitors: row.metrics |> Map.fetch!("ga:users") |> parse_number(),
visits: row.metrics |> Map.fetch!("ga:sessions") |> parse_number(),
bounces: row.metrics |> Map.fetch!("ga:bounces") |> parse_number(),
visit_duration: row.metrics |> Map.fetch!("ga:sessionDuration") |> 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, "ga:browser")
%{
site_id: site_id,
import_id: import_id,
date: get_date(row),
browser: Map.get(@browser_google_to_plausible, browser, browser),
visitors: row.metrics |> Map.fetch!("ga:users") |> parse_number(),
visits: row.metrics |> Map.fetch!("ga:sessions") |> parse_number(),
bounces: row.metrics |> Map.fetch!("ga:bounces") |> parse_number(),
visit_duration: row.metrics |> Map.fetch!("ga:sessionDuration") |> 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, "ga:operatingSystem")
%{
site_id: site_id,
import_id: import_id,
date: get_date(row),
operating_system: Map.get(@os_google_to_plausible, os, os),
visitors: row.metrics |> Map.fetch!("ga:users") |> parse_number(),
visits: row.metrics |> Map.fetch!("ga:sessions") |> parse_number(),
bounces: row.metrics |> Map.fetch!("ga:bounces") |> parse_number(),
visit_duration: row.metrics |> Map.fetch!("ga:sessionDuration") |> parse_number()
}
end
defp get_date(%{dimensions: %{"ga:date" => date}}) do
date
|> Timex.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_referrer(nil), do: nil
defp parse_referrer("(direct)"), do: nil
defp parse_referrer("google"), do: "Google"
defp parse_referrer("bing"), do: "Bing"
defp parse_referrer("duckduckgo"), do: "DuckDuckGo"
defp parse_referrer(ref) do
RefInspector.parse("https://" <> ref)
|> PlausibleWeb.RefInspector.parse()
def import_data(_site_import, _opts) do
raise "Importing data not supported"
end
end

View File

@ -749,7 +749,7 @@ defmodule PlausibleWeb.AuthController do
"import" ->
redirect(conn,
external:
Routes.google_analytics_path(conn, :property_or_view_form, site.domain,
Routes.google_analytics_path(conn, :property_form, site.domain,
access_token: res["access_token"],
refresh_token: res["refresh_token"],
expires_at: NaiveDateTime.to_iso8601(expires_at)

View File

@ -10,31 +10,7 @@ defmodule PlausibleWeb.GoogleAnalyticsController do
plug(PlausibleWeb.AuthorizeSiteAccess, [:owner, :admin, :super_admin])
def user_metric_notice(conn, %{
"property_or_view" => property_or_view,
"access_token" => access_token,
"refresh_token" => refresh_token,
"expires_at" => expires_at,
"start_date" => start_date,
"end_date" => end_date
}) do
site = conn.assigns.site
conn
|> assign(:skip_plausible_tracking, true)
|> render("user_metric_form.html",
site: site,
property_or_view: property_or_view,
access_token: access_token,
refresh_token: refresh_token,
expires_at: expires_at,
start_date: start_date,
end_date: end_date,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
def property_or_view_form(
def property_form(
conn,
%{
"access_token" => access_token,
@ -46,7 +22,7 @@ defmodule PlausibleWeb.GoogleAnalyticsController do
redirect_route = Routes.site_path(conn, :settings_imports_exports, site.domain)
result = Google.API.list_properties_and_views(access_token)
result = Google.API.list_properties(access_token)
error =
case params["error"] do
@ -61,16 +37,16 @@ defmodule PlausibleWeb.GoogleAnalyticsController do
end
case result do
{:ok, properties_and_views} ->
{:ok, properties} ->
conn
|> assign(:skip_plausible_tracking, true)
|> render("property_or_view_form.html",
|> render("property_form.html",
access_token: access_token,
refresh_token: refresh_token,
expires_at: expires_at,
site: conn.assigns.site,
properties_and_views: properties_and_views,
selected_property_or_view_error: error,
properties: properties,
selected_property_error: error,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
@ -102,19 +78,16 @@ defmodule PlausibleWeb.GoogleAnalyticsController do
conn
|> put_flash(
:error,
"We were unable to list your Google Analytics properties and views. If the problem persists, please contact support for assistance."
"We were unable to list your Google Analytics properties. If the problem persists, please contact support for assistance."
)
|> redirect(external: redirect_route)
end
end
# see https://stackoverflow.com/a/57416769
@universal_analytics_new_user_metric_date ~D[2016-08-24]
def property_or_view(
def property(
conn,
%{
"property_or_view" => property_or_view,
"property" => property,
"access_token" => access_token,
"refresh_token" => refresh_token,
"expires_at" => expires_at
@ -124,22 +97,14 @@ defmodule PlausibleWeb.GoogleAnalyticsController do
redirect_route = Routes.site_path(conn, :settings_imports_exports, site.domain)
with {:ok, api_start_date} <-
Google.API.get_analytics_start_date(access_token, property_or_view),
{:ok, api_end_date} <- Google.API.get_analytics_end_date(access_token, property_or_view),
with {:ok, api_start_date} <- Google.API.get_analytics_start_date(access_token, property),
{:ok, api_end_date} <- Google.API.get_analytics_end_date(access_token, property),
:ok <- ensure_dates(api_start_date, api_end_date),
{:ok, start_date, end_date} <- Imported.clamp_dates(site, api_start_date, api_end_date) do
action =
if Timex.before?(api_start_date, @universal_analytics_new_user_metric_date) do
:user_metric_notice
else
:confirm
end
redirect(conn,
external:
Routes.google_analytics_path(conn, action, site.domain,
property_or_view: property_or_view,
Routes.google_analytics_path(conn, :confirm, site.domain,
property: property,
access_token: access_token,
refresh_token: refresh_token,
expires_at: expires_at,
@ -154,7 +119,7 @@ defmodule PlausibleWeb.GoogleAnalyticsController do
|> Map.take(["access_token", "refresh_token", "expires_at"])
|> Map.put("error", Atom.to_string(error))
property_or_view_form(conn, params)
property_form(conn, params)
{:error, :rate_limit_exceeded} ->
conn
@ -191,7 +156,7 @@ defmodule PlausibleWeb.GoogleAnalyticsController do
end
def confirm(conn, %{
"property_or_view" => property_or_view,
"property" => property,
"access_token" => access_token,
"refresh_token" => refresh_token,
"expires_at" => expires_at,
@ -205,8 +170,8 @@ defmodule PlausibleWeb.GoogleAnalyticsController do
redirect_route = Routes.site_path(conn, :settings_imports_exports, site.domain)
case Google.API.get_property_or_view(access_token, property_or_view) do
{:ok, %{name: property_or_view_name, id: property_or_view}} ->
case Google.API.get_property(access_token, property) do
{:ok, %{name: property_name, id: property}} ->
conn
|> assign(:skip_plausible_tracking, true)
|> render("confirm.html",
@ -214,11 +179,10 @@ defmodule PlausibleWeb.GoogleAnalyticsController do
refresh_token: refresh_token,
expires_at: expires_at,
site: site,
selected_property_or_view: property_or_view,
selected_property_or_view_name: property_or_view_name,
selected_property: property,
selected_property_name: property_name,
start_date: start_date,
end_date: end_date,
property?: Google.API.property?(property_or_view),
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
@ -265,7 +229,7 @@ defmodule PlausibleWeb.GoogleAnalyticsController do
end
def import(conn, %{
"property_or_view" => property_or_view,
"property" => property,
"start_date" => start_date,
"end_date" => end_date,
"access_token" => access_token,
@ -281,7 +245,8 @@ defmodule PlausibleWeb.GoogleAnalyticsController do
redirect_route = Routes.site_path(conn, :settings_imports_exports, site.domain)
import_opts = [
label: property_or_view,
label: property,
property: property,
start_date: start_date,
end_date: end_date,
access_token: access_token,
@ -291,7 +256,7 @@ defmodule PlausibleWeb.GoogleAnalyticsController do
with {:ok, start_date, end_date} <- Imported.clamp_dates(site, start_date, end_date),
import_opts = [{:start_date, start_date}, {:end_date, end_date} | import_opts],
{:ok, _} <- schedule_job(site, current_user, property_or_view, import_opts) do
{:ok, _} <- Imported.GoogleAnalytics4.new_import(site, current_user, import_opts) do
conn
|> put_flash(:success, "Import scheduled. An email will be sent when it completes.")
|> redirect(external: redirect_route)
@ -316,14 +281,4 @@ defmodule PlausibleWeb.GoogleAnalyticsController do
defp ensure_dates(%Date{}, %Date{}), do: :ok
defp ensure_dates(_, _), do: {:error, :no_data}
defp schedule_job(site, current_user, property_or_view, opts) do
if Google.API.property?(property_or_view) do
opts = Keyword.put(opts, :property, property_or_view)
Imported.GoogleAnalytics4.new_import(site, current_user, opts)
else
opts = Keyword.put(opts, :view_id, property_or_view)
Imported.UniversalAnalytics.new_import(site, current_user, opts)
end
end
end

View File

@ -396,17 +396,13 @@ defmodule PlausibleWeb.Router do
delete "/:website", SiteController, :delete_site
delete "/:website/stats", SiteController, :reset_stats
get "/:website/import/google-analytics/property-or-view",
get "/:website/import/google-analytics/property",
GoogleAnalyticsController,
:property_or_view_form
:property_form
post "/:website/import/google-analytics/property-or-view",
post "/:website/import/google-analytics/property",
GoogleAnalyticsController,
:property_or_view
get "/:website/import/google-analytics/user-metric",
GoogleAnalyticsController,
:user_metric_notice
:property
get "/:website/import/google-analytics/confirm", GoogleAnalyticsController, :confirm
post "/:website/settings/google-import", GoogleAnalyticsController, :import

View File

@ -6,27 +6,22 @@
<%= hidden_input(f, :expires_at, value: @expires_at) %>
<div class="mt-6 text-sm text-gray-500 dark:text-gray-200">
Stats from this
<%= if @property? do %>
property
<% else %>
view
<% end %>
and time period will be imported from your Google Analytics account to your Plausible dashboard
Stats from this property and time period will be imported from
your Google Analytics account to your Plausible dashboard
</div>
<div class="mt-6">
<%= styled_label(
f,
:property_or_view,
"Google Analytics #{if @property?, do: "property", else: "view"}"
:property,
"Google Analytics property"
) %>
<span class="block w-full text-base dark:text-gray-100 sm:text-sm dark:bg-gray-800">
<%= @selected_property_or_view_name %>
<%= @selected_property_name %>
</span>
<%= hidden_input(f, :property_or_view,
<%= hidden_input(f, :property,
readonly: "true",
value: @selected_property_or_view
value: @selected_property
) %>
</div>
<div class="flex justify-between mt-3">
@ -51,8 +46,8 @@
<p class="text-sm mt-4 sm:mt-0 dark:text-gray-100">
<a
href={
Routes.google_analytics_path(@conn, :property_or_view, @site.domain,
property_or_view: @selected_property_or_view,
Routes.google_analytics_path(@conn, :property, @site.domain,
property: @selected_property,
access_token: @access_token,
refresh_token: @refresh_token,
expires_at: @expires_at

View File

@ -1,4 +1,4 @@
<%= form_for @conn, Routes.google_analytics_path(@conn, :property_or_view, @site.domain), [onsubmit: "continueButton.disabled = true; return true;", class: "max-w-md w-full mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
<%= form_for @conn, Routes.google_analytics_path(@conn, :property, @site.domain), [onsubmit: "continueButton.disabled = true; return true;", class: "max-w-md w-full mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
<h2 class="text-xl font-black dark:text-gray-100">Import from Google Analytics</h2>
<%= hidden_input(f, :access_token, value: @access_token) %>
@ -6,16 +6,16 @@
<%= hidden_input(f, :expires_at, value: @expires_at) %>
<div class="mt-6 text-sm text-gray-500 dark:text-gray-200">
Choose the property or view in your Google Analytics account that will be imported to the <%= @site.domain %> dashboard.
Choose the property in your Google Analytics account that will be imported to the <%= @site.domain %> dashboard.
</div>
<div class="mt-3">
<%= styled_label(f, :property_or_view, "Google Analytics property or view") %>
<%= styled_select(f, :property_or_view, @properties_and_views,
prompt: "(Choose property or view)",
<%= styled_label(f, :property, "Google Analytics property") %>
<%= styled_select(f, :property, @properties,
prompt: "(Choose property)",
required: "true"
) %>
<%= styled_error(@conn.assigns[:selected_property_or_view_error]) %>
<%= styled_error(@conn.assigns[:selected_property_error]) %>
</div>
<div class="mt-6 flex flex-col-reverse sm:flex-row justify-between items-center">

View File

@ -1,65 +0,0 @@
<div class="max-w-md w-full mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8">
<h2 class="text-xl font-black dark:text-gray-100">Import from Google Analytics</h2>
<div class="mt-6 text-sm text-gray-500 dark:text-gray-200">
<p>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 inline text-orange-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
Important: Since your GA property includes data from before 23rd August 2016, you have to take an extra step to make sure we can import data smoothly.
</p>
<ol class="mt-4">
<li>1. Navigate to the GA property you want to import from</li>
<li>2. Go to Admin &gt; Property Settings &gt; User Analysis</li>
<li>3. Make sure <i>Enable Users Metric in Reporting</i> is <b>OFF</b></li>
</ol>
<p class="mt-4">
The setting may take a few minutes to take effect. If your imported data is showing 0 visitors in unexpected places, it's probably caused by this and you
can try importing again later.
</p>
</div>
<div class="mt-6 flex flex-col-reverse sm:flex-row justify-between items-center">
<p class="text-sm mt-4 sm:mt-0 dark:text-gray-100">
<a
href={
Routes.google_analytics_path(@conn, :property_or_view, @site.domain,
property_or_view: @property_or_view,
access_token: @access_token,
refresh_token: @refresh_token,
expires_at: @expires_at
)
}
class="underline text-indigo-600"
>
Go back
</a>
</p>
<%= link("Continue ->",
to:
Routes.google_analytics_path(@conn, :confirm, @site.domain,
property_or_view: @property_or_view,
access_token: @access_token,
refresh_token: @refresh_token,
expires_at: @expires_at,
start_date: @start_date,
end_date: @end_date
),
class: "button sm:w-auto w-full"
) %>
</div>
</div>

View File

@ -3,7 +3,6 @@ defmodule Plausible.Google.APITest do
use Plausible.Test.Support.HTTPMocker
alias Plausible.Google
alias Plausible.Imported.UniversalAnalytics
import ExUnit.CaptureLog
import Mox
@ -11,220 +10,6 @@ defmodule Plausible.Google.APITest do
setup [:create_user, :create_new_site]
@refresh_token_body Jason.decode!(File.read!("fixture/ga_refresh_token.json"))
@full_report_mock [
"fixture/ga_report_imported_visitors.json",
"fixture/ga_report_imported_sources.json",
"fixture/ga_report_imported_pages.json",
"fixture/ga_report_imported_entry_pages.json",
"fixture/ga_report_imported_exit_pages.json",
"fixture/ga_report_imported_locations.json",
"fixture/ga_report_imported_devices.json",
"fixture/ga_report_imported_browsers.json",
"fixture/ga_report_imported_operating_systems.json"
]
|> Enum.map(&File.read!/1)
|> Enum.map(&Jason.decode!/1)
@tag :slow
test "imports page views from Google Analytics", %{site: site} do
mock_http_with("google_analytics_import#1.json")
view_id = "54297898"
date_range = Date.range(~D[2011-01-01], ~D[2022-07-19])
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.to_iso8601()
auth = {"***", "refresh_token", future}
{:ok, buffer} = Plausible.Imported.Buffer.start_link()
site_import = insert(:site_import)
persist_fn = fn table, rows ->
records = UniversalAnalytics.from_report(rows, site.id, site_import.id, table)
Plausible.Imported.Buffer.insert_many(buffer, table, records)
end
assert :ok == Google.UA.API.import_analytics(date_range, view_id, auth, persist_fn)
Plausible.Imported.Buffer.flush(buffer)
Plausible.Imported.Buffer.stop(buffer)
assert 1_495_150 == Plausible.Stats.Clickhouse.imported_pageview_count(site)
end
@tag :slow
test "import_analytics/4 refreshes OAuth token when needed", %{site: site} do
past = DateTime.add(DateTime.utc_now(), -3600, :second)
auth = {"redacted_access_token", "redacted_refresh_token", DateTime.to_iso8601(past)}
range = Date.range(~D[2020-01-01], ~D[2020-02-02])
expect(Plausible.HTTPClient.Mock, :post, fn "https://www.googleapis.com/oauth2/v4/token",
headers,
body ->
assert [{"content-type", "application/x-www-form-urlencoded"}] == headers
assert %{
grant_type: :refresh_token,
redirect_uri: "http://localhost:8000/auth/google/callback",
refresh_token: "redacted_refresh_token"
} = body
{:ok, %Finch.Response{status: 200, body: @refresh_token_body}}
end)
for report <- @full_report_mock do
expect(Plausible.HTTPClient.Mock, :post, fn _url, headers, _body, _opts ->
assert [{"Authorization", "Bearer 1/fFAGRNJru1FTz70BzhT3Zg"}] == headers
{:ok, %Finch.Response{status: 200, body: report}}
end)
end
{:ok, buffer} = Plausible.Imported.Buffer.start_link()
site_import = insert(:site_import)
persist_fn = fn table, rows ->
records = UniversalAnalytics.from_report(rows, site.id, site_import.id, table)
Plausible.Imported.Buffer.insert_many(buffer, table, records)
end
assert :ok == Google.UA.API.import_analytics(range, "123551", auth, persist_fn)
Plausible.Imported.Buffer.flush(buffer)
Plausible.Imported.Buffer.stop(buffer)
end
describe "fetch_and_persist/4" do
@ok_response Jason.decode!(File.read!("fixture/ga_batch_report.json"))
@no_report_response Jason.decode!(File.read!("fixture/ga_report_empty_rows.json"))
@tag :slow
test "will fetch and persist import data from Google Analytics" do
request = %Plausible.Google.UA.ReportRequest{
dataset: "imported_exit_pages",
view_id: "123",
date_range: Date.range(~D[2022-01-01], ~D[2022-02-01]),
dimensions: ["ga:date", "ga:exitPagePath"],
metrics: ["ga:users", "ga:exits"],
access_token: "fake-token",
page_token: nil,
page_size: 10_000
}
expect(
Plausible.HTTPClient.Mock,
:post,
fn
"https://analyticsreporting.googleapis.com/v4/reports:batchGet",
[{"Authorization", "Bearer fake-token"}],
%{
reportRequests: [
%{
dateRanges: [%{endDate: ~D[2022-02-01], startDate: ~D[2022-01-01]}],
dimensions: [
%{histogramBuckets: [], name: "ga:date"},
%{histogramBuckets: [], name: "ga:exitPagePath"}
],
hideTotals: true,
hideValueRanges: true,
metrics: [%{expression: "ga:users"}, %{expression: "ga:exits"}],
orderBys: [%{fieldName: "ga:date", sortOrder: "DESCENDING"}],
pageSize: 10_000,
pageToken: nil,
viewId: "123"
}
]
},
[receive_timeout: 60_000] ->
{:ok, %Finch.Response{status: 200, body: @ok_response}}
end
)
assert :ok =
Google.UA.API.fetch_and_persist(request,
sleep_time: 0,
persist_fn: fn dataset, row ->
assert dataset == "imported_exit_pages"
assert length(row) == 1479
:ok
end
)
end
test "retries HTTP request up to 5 times before raising the last error" do
expect(
Plausible.HTTPClient.Mock,
:post,
5,
fn
"https://analyticsreporting.googleapis.com/v4/reports:batchGet",
_,
_,
[receive_timeout: 60_000] ->
Enum.random([
{:error, %Mint.TransportError{reason: :nxdomain}},
{:error, %{reason: %Finch.Response{status: 500}}}
])
end
)
request = %Plausible.Google.UA.ReportRequest{
view_id: "123",
date_range: Date.range(~D[2022-01-01], ~D[2022-02-01]),
dimensions: ["ga:date"],
metrics: ["ga:users"],
access_token: "fake-token",
page_token: nil,
page_size: 10_000
}
assert {:error, :request_failed} =
Google.UA.API.fetch_and_persist(request,
sleep_time: 0,
persist_fn: fn _dataset, _rows -> :ok end
)
end
test "does not fail when report does not have rows key" do
expect(
Plausible.HTTPClient.Mock,
:post,
fn
"https://analyticsreporting.googleapis.com/v4/reports:batchGet",
_,
_,
[receive_timeout: 60_000] ->
{:ok, %Finch.Response{status: 200, body: @no_report_response}}
end
)
request = %Plausible.Google.UA.ReportRequest{
dataset: "imported_exit_pages",
view_id: "123",
date_range: Date.range(~D[2022-01-01], ~D[2022-02-01]),
dimensions: ["ga:date", "ga:exitPagePath"],
metrics: ["ga:users", "ga:exits"],
access_token: "fake-token",
page_token: nil,
page_size: 10_000
}
assert :ok ==
Google.UA.API.fetch_and_persist(request,
sleep_time: 0,
persist_fn: fn dataset, rows ->
assert dataset == "imported_exit_pages"
assert rows == []
:ok
end
)
end
end
describe "fetch_stats/3 errors" do
setup %{user: user, site: site} do
insert(:google_auth,
@ -393,60 +178,4 @@ defmodule Plausible.Google.APITest do
assert {:error, :unsupported_filters} = Google.API.fetch_stats(site, query, 5)
end
end
test "list_views/1 returns view IDs grouped by hostname" do
expect(
Plausible.HTTPClient.Mock,
:get,
fn url, _headers ->
assert url ==
"https://www.googleapis.com/analytics/v3/management/accounts/~all/webproperties/~all/profiles"
response = "fixture/ga_list_views.json" |> File.read!() |> Jason.decode!()
{:ok, %Finch.Response{status: 200, body: response}}
end
)
assert {:ok,
[
{"one.test", [{"57238190 - one.test", "57238190"}]},
{"two.test", [{"54460083 - two.test", "54460083"}]}
]} == Google.UA.API.list_views("access_token")
end
test "list_views/1 returns authentication_failed when request fails with HTTP 403" do
expect(
Plausible.HTTPClient.Mock,
:get,
fn _url, _headers ->
{:error, %Plausible.HTTPClient.Non200Error{reason: %{status: 403, body: %{}}}}
end
)
assert {:error, :authentication_failed} == Google.UA.API.list_views("access_token")
end
test "list_views/1 returns authentication_failed when request fails with HTTP 401" do
expect(
Plausible.HTTPClient.Mock,
:get,
fn _url, _headers ->
{:error, %Plausible.HTTPClient.Non200Error{reason: %{status: 401, body: %{}}}}
end
)
assert {:error, :authentication_failed} == Google.UA.API.list_views("access_token")
end
test "list_views/1 returns error when request fails with HTTP 500" do
expect(
Plausible.HTTPClient.Mock,
:get,
fn _url, _headers ->
{:error, %Plausible.HTTPClient.Non200Error{reason: %{status: 500, body: "server error"}}}
end
)
assert {:error, :unknown} == Google.UA.API.list_views("access_token")
end
end

View File

@ -1,120 +0,0 @@
defmodule Plausible.Imported.UniversalAnalyticsTest do
use Plausible.DataCase, async: true
use Plausible.Test.Support.HTTPMocker
alias Plausible.Imported.SiteImport
alias Plausible.Imported.UniversalAnalytics
require Plausible.Imported.SiteImport
setup [:create_user, :create_new_site]
describe "new_import/3 and parse_args/1" do
test "parses job args properly", %{user: user, site: site} do
expires_at = NaiveDateTime.to_iso8601(NaiveDateTime.utc_now())
assert {:ok, job} =
UniversalAnalytics.new_import(site, user,
view_id: "123",
label: "123",
start_date: "2023-10-01",
end_date: "2024-01-02",
access_token: "access123",
refresh_token: "refresh123",
token_expires_at: expires_at,
legacy: true
)
assert %Oban.Job{
args:
%{
"import_id" => import_id,
"view_id" => "123",
"start_date" => "2023-10-01",
"end_date" => "2024-01-02",
"access_token" => "access123",
"refresh_token" => "refresh123",
"token_expires_at" => ^expires_at
} = args
} = Repo.reload!(job)
assert [
%{
id: ^import_id,
label: "123",
source: :universal_analytics,
start_date: ~D[2023-10-01],
end_date: ~D[2024-01-02],
status: SiteImport.pending(),
legacy: true
}
] = Plausible.Imported.list_all_imports(site)
assert opts = [_ | _] = UniversalAnalytics.parse_args(args)
assert opts[:view_id] == "123"
assert opts[:date_range] == Date.range(~D[2023-10-01], ~D[2024-01-02])
assert opts[:auth] == {"access123", "refresh123", expires_at}
end
test "creates SiteImport with legacy flag set to false when instructed", %{
user: user,
site: site
} do
expires_at = NaiveDateTime.to_iso8601(NaiveDateTime.utc_now())
assert {:ok, job} =
UniversalAnalytics.new_import(site, user,
view_id: 123,
start_date: "2023-10-01",
end_date: "2024-01-02",
access_token: "access123",
refresh_token: "refresh123",
token_expires_at: expires_at,
legacy: false
)
assert %Oban.Job{args: %{"import_id" => import_id}} = Repo.reload!(job)
assert [
%{
id: ^import_id,
legacy: false
}
] = Plausible.Imported.list_all_imports(site)
end
end
describe "import_data/2" do
@tag :slow
test "imports page views from Google Analytics", %{user: user, site: site} do
mock_http_with("google_analytics_import#1.json")
view_id = "54297898"
start_date = ~D[2011-01-01]
end_date = ~D[2022-07-19]
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.to_iso8601()
access_token = "***"
refresh_token = "refresh_token"
{:ok, job} =
UniversalAnalytics.new_import(
site,
user,
view_id: view_id,
start_date: start_date,
end_date: end_date,
access_token: access_token,
refresh_token: refresh_token,
token_expires_at: future
)
job = Repo.reload!(job)
Plausible.Workers.ImportAnalytics.perform(job)
assert 1_495_150 == Plausible.Stats.Clickhouse.imported_pageview_count(site)
end
end
end

View File

@ -12,39 +12,10 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
setup :verify_on_exit!
describe "GET /:website/import/google-analytics/user-metric" do
describe "GET /:website/import/google-analytics/property" do
setup [:create_user, :log_in, :create_new_site]
test "renders with link to confirmation page", %{conn: conn, site: site} do
response =
conn
|> get("/#{site.domain}/import/google-analytics/user-metric", %{
"property_or_view" => "123456",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777",
"start_date" => "2020-02-22",
"end_date" => "2022-09-22"
})
|> html_response(200)
assert response =~
PlausibleWeb.Router.Helpers.google_analytics_path(conn, :confirm, site.domain,
property_or_view: "123456",
access_token: "token",
refresh_token: "foo",
expires_at: "2022-09-22T20:01:37.112777",
start_date: "2020-02-22",
end_date: "2022-09-22"
)
|> String.replace("&", "&amp;")
end
end
describe "GET /:website/import/google-analytics/property-or-view" do
setup [:create_user, :log_in, :create_new_site]
test "lists Google Analytics properties WITHOUT UA views", %{conn: conn, site: site} do
test "lists Google Analytics properties", %{conn: conn, site: site} do
expect(
Plausible.HTTPClient.Mock,
:get,
@ -56,15 +27,13 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
response =
conn
|> get("/#{site.domain}/import/google-analytics/property-or-view", %{
|> get("/#{site.domain}/import/google-analytics/property", %{
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
})
|> html_response(200)
refute response =~ "57238190 - one.test"
refute response =~ "54460083 - two.test"
assert response =~ "account.one - GA4 (properties/428685906)"
assert response =~ "GA4 - Flood-It! (properties/153293282)"
assert response =~ "GA4 - Google Merch Shop (properties/213025502)"
@ -84,7 +53,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
conn
|> get("/#{site.domain}/import/google-analytics/property-or-view", %{
|> get("/#{site.domain}/import/google-analytics/property", %{
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
@ -115,7 +84,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
conn
|> get("/#{site.domain}/import/google-analytics/property-or-view", %{
|> get("/#{site.domain}/import/google-analytics/property", %{
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
@ -146,7 +115,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
conn
|> get("/#{site.domain}/import/google-analytics/property-or-view", %{
|> get("/#{site.domain}/import/google-analytics/property", %{
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
@ -178,7 +147,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
conn
|> get("/#{site.domain}/import/google-analytics/property-or-view", %{
|> get("/#{site.domain}/import/google-analytics/property", %{
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
@ -192,78 +161,14 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"We were unable to list your Google Analytics properties and views"
"We were unable to list your Google Analytics properties"
end
end
describe "POST /:website/import/google-analytics/property-or-view" do
describe "POST /:website/import/google-analytics/property" do
setup [:create_user, :log_in, :create_new_site]
test "redirects to user metrics notice", %{conn: conn, site: site} do
expect(
Plausible.HTTPClient.Mock,
:post,
fn _url, _opts, _params ->
body = "fixture/ga_start_date.json" |> File.read!() |> Jason.decode!()
{:ok, %Finch.Response{body: body, status: 200}}
end
)
expect(
Plausible.HTTPClient.Mock,
:post,
fn _url, _opts, _params ->
body = "fixture/ga_end_date.json" |> File.read!() |> Jason.decode!()
{:ok, %Finch.Response{body: body, status: 200}}
end
)
conn =
conn
|> post("/#{site.domain}/import/google-analytics/property-or-view", %{
"property_or_view" => "57238190",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
})
assert redirected_to(conn, 302) =~
"/#{URI.encode_www_form(site.domain)}/import/google-analytics/user-metric"
end
test "redirects to confirmation", %{conn: conn, site: site} do
expect(
Plausible.HTTPClient.Mock,
:post,
fn _url, _opts, _params ->
body = "fixture/ga_start_date_later.json" |> File.read!() |> Jason.decode!()
{:ok, %Finch.Response{body: body, status: 200}}
end
)
expect(
Plausible.HTTPClient.Mock,
:post,
fn _url, _opts, _params ->
body = "fixture/ga_end_date.json" |> File.read!() |> Jason.decode!()
{:ok, %Finch.Response{body: body, status: 200}}
end
)
conn =
conn
|> post("/#{site.domain}/import/google-analytics/property-or-view", %{
"property_or_view" => "57238190",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
})
assert redirected_to(conn, 302) =~
"/#{URI.encode_www_form(site.domain)}/import/google-analytics/confirm"
end
test "redirects to confirmation (GA4)", %{conn: conn, site: site} do
expect(
Plausible.HTTPClient.Mock,
:post,
@ -284,8 +189,8 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
conn
|> post("/#{site.domain}/import/google-analytics/property-or-view", %{
"property_or_view" => "properties/428685906",
|> post("/#{site.domain}/import/google-analytics/property", %{
"property" => "properties/428685906",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
@ -336,8 +241,8 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
response =
conn
|> post("/#{site.domain}/import/google-analytics/property-or-view", %{
"property_or_view" => "properties/428685906",
|> post("/#{site.domain}/import/google-analytics/property", %{
"property" => "properties/428685906",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
@ -376,8 +281,8 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
response =
conn
|> post("/#{site.domain}/import/google-analytics/property-or-view", %{
"property_or_view" => "properties/428685906",
|> post("/#{site.domain}/import/google-analytics/property", %{
"property" => "properties/428685906",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
@ -387,7 +292,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
assert response =~ "No data found. Nothing to import."
end
test "redirects to imports and exports on failed property/view choice with flash error",
test "redirects to imports and exports on failed property choice with flash error",
%{
conn: conn,
site: site
@ -402,8 +307,8 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
conn
|> post("/#{site.domain}/import/google-analytics/property-or-view", %{
"property_or_view" => "properties/428685906",
|> post("/#{site.domain}/import/google-analytics/property", %{
"property" => "properties/428685906",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
@ -435,8 +340,8 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
conn
|> post("/#{site.domain}/import/google-analytics/property-or-view", %{
"property_or_view" => "properties/428685906",
|> post("/#{site.domain}/import/google-analytics/property", %{
"property" => "properties/428685906",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
@ -468,8 +373,8 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
conn
|> post("/#{site.domain}/import/google-analytics/property-or-view", %{
"property_or_view" => "properties/428685906",
|> post("/#{site.domain}/import/google-analytics/property", %{
"property" => "properties/428685906",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
@ -501,8 +406,8 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
conn
|> post("/#{site.domain}/import/google-analytics/property-or-view", %{
"property_or_view" => "properties/428685906",
|> post("/#{site.domain}/import/google-analytics/property", %{
"property" => "properties/428685906",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
@ -523,45 +428,6 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
describe "GET /:website/import/google-analytics/confirm" do
setup [:create_user, :log_in, :create_new_site]
test "renders confirmation form for Universal Analytics import", %{conn: conn, site: site} do
expect(
Plausible.HTTPClient.Mock,
:get,
fn _url, _headers ->
body = "fixture/ga_list_views.json" |> File.read!() |> Jason.decode!()
{:ok, %Finch.Response{body: body, status: 200}}
end
)
response =
conn
|> get("/#{site.domain}/import/google-analytics/confirm", %{
"property_or_view" => "57238190",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777",
"start_date" => "2012-01-18",
"end_date" => "2022-09-22"
})
|> html_response(200)
action_url = PlausibleWeb.Router.Helpers.google_analytics_path(conn, :import, site.domain)
assert text_of_attr(response, "form", "action") == action_url
assert text_of_attr(response, ~s|input[name=access_token]|, "value") == "token"
assert text_of_attr(response, ~s|input[name=refresh_token]|, "value") == "foo"
assert text_of_attr(response, ~s|input[name=expires_at]|, "value") ==
"2022-09-22T20:01:37.112777"
assert text_of_attr(response, ~s|input[name=property_or_view]|, "value") == "57238190"
assert text_of_attr(response, ~s|input[name=start_date]|, "value") == "2012-01-18"
assert text_of_attr(response, ~s|input[name=end_date]|, "value") == "2022-09-22"
end
test "renders confirmation form for Google Analytics 4 import", %{conn: conn, site: site} do
expect(
Plausible.HTTPClient.Mock,
@ -575,7 +441,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
response =
conn
|> get("/#{site.domain}/import/google-analytics/confirm", %{
"property_or_view" => "properties/428685444",
"property" => "properties/428685444",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777",
@ -594,7 +460,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
assert text_of_attr(response, ~s|input[name=expires_at]|, "value") ==
"2022-09-22T20:01:37.112777"
assert text_of_attr(response, ~s|input[name=property_or_view]|, "value") ==
assert text_of_attr(response, ~s|input[name=property]|, "value") ==
"properties/428685444"
assert text_of_attr(response, ~s|input[name=start_date]|, "value") == "2024-02-22"
@ -602,7 +468,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
assert text_of_attr(response, ~s|input[name=end_date]|, "value") == "2024-02-26"
end
test "redirects to imports and exports on failed property/view retrieval with flash error",
test "redirects to imports and exports on failed property retrieval with flash error",
%{
conn: conn,
site: site
@ -618,7 +484,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
conn
|> get("/#{site.domain}/import/google-analytics/confirm", %{
"property_or_view" => "properties/428685906",
"property" => "properties/428685906",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777",
@ -653,7 +519,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
conn
|> get("/#{site.domain}/import/google-analytics/confirm", %{
"property_or_view" => "properties/428685906",
"property" => "properties/428685906",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777",
@ -688,7 +554,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
conn
|> get("/#{site.domain}/import/google-analytics/confirm", %{
"property_or_view" => "properties/428685906",
"property" => "properties/428685906",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777",
@ -723,7 +589,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
conn
|> get("/#{site.domain}/import/google-analytics/confirm", %{
"property_or_view" => "properties/428685906",
"property" => "properties/428685906",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777",
@ -749,7 +615,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
test "creates Google Analytics 4 site import instance", %{conn: conn, site: site} do
conn =
post(conn, "/#{site.domain}/settings/google-import", %{
"property_or_view" => "properties/123456",
"property" => "properties/123456",
"start_date" => "2018-03-01",
"end_date" => "2022-03-01",
"access_token" => "token",
@ -767,54 +633,9 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
assert site_import.status == SiteImport.pending()
end
test "creates Universal Analytics site import instance", %{conn: conn, site: site} do
conn =
post(conn, "/#{site.domain}/settings/google-import", %{
"property_or_view" => "123456",
"start_date" => "2018-03-01",
"end_date" => "2022-03-01",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
})
assert redirected_to(conn, 302) ==
PlausibleWeb.Router.Helpers.site_path(conn, :settings_imports_exports, site.domain)
[site_import] = Plausible.Imported.list_all_imports(site)
assert site_import.source == :universal_analytics
assert site_import.end_date == ~D[2022-03-01]
assert site_import.status == SiteImport.pending()
end
test "redirects to imports and exports when creating UA job", %{
conn: conn,
site: site
} do
conn =
post(conn, "/#{site.domain}/settings/google-import", %{
"property_or_view" => "123456",
"start_date" => "2018-03-01",
"end_date" => "2022-03-01",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
})
assert redirected_to(conn, 302) ==
PlausibleWeb.Router.Helpers.site_path(conn, :settings_imports_exports, site.domain)
[site_import] = Plausible.Imported.list_all_imports(site)
assert site_import.source == :universal_analytics
assert site_import.end_date == ~D[2022-03-01]
assert site_import.status == SiteImport.pending()
end
test "schedules a Google Analytics 4 import job in Oban", %{conn: conn, site: site} do
post(conn, "/#{site.domain}/settings/google-import", %{
"property_or_view" => "properties/123456",
"property" => "properties/123456",
"start_date" => "2018-03-01",
"end_date" => "2022-03-01",
"access_token" => "token",
@ -838,32 +659,6 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
)
end
test "schedules a Universal Analytics import job in Oban", %{conn: conn, site: site} do
post(conn, "/#{site.domain}/settings/google-import", %{
"property_or_view" => "123456",
"start_date" => "2018-03-01",
"end_date" => "2022-03-01",
"access_token" => "token",
"refresh_token" => "foo",
"expires_at" => "2022-09-22T20:01:37.112777"
})
assert [%{id: import_id, legacy: false}] = Plausible.Imported.list_all_imports(site)
assert_enqueued(
worker: Plausible.Workers.ImportAnalytics,
args: %{
"import_id" => import_id,
"view_id" => "123456",
"start_date" => "2018-03-01",
"end_date" => "2022-03-01",
"access_token" => "token",
"refresh_token" => "foo",
"token_expires_at" => "2022-09-22T20:01:37.112777"
}
)
end
test "does not start another import when there's any other in progress for the same site", %{
conn: conn,
site: site,
@ -876,7 +671,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
)
post(conn, "/#{site.domain}/settings/google-import", %{
"property_or_view" => "properties/123456",
"property" => "properties/123456",
"start_date" => "2018-03-01",
"end_date" => "2022-03-01",
"access_token" => "token",
@ -886,7 +681,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
post(conn, "/#{site.domain}/settings/google-import", %{
"property_or_view" => "123456",
"property" => "123456",
"start_date" => "2018-03-01",
"end_date" => "2022-03-01",
"access_token" => "token",
@ -919,7 +714,7 @@ defmodule PlausibleWeb.GoogleAnalyticsControllerTest do
conn =
post(conn, "/#{site.domain}/settings/google-import", %{
"property_or_view" => "123456",
"property" => "123456",
"start_date" => "2023-03-01",
"end_date" => "2022-03-01",
"access_token" => "token",