| defmodule Plausible.Imported.Importer do |
| @moduledoc """ |
| Behaviour that should be implemented for each import source. |
| |
| All imports are executed as background jobs run via `Plausible.Workers.ImportAnalytics` |
| Oban worker. Each import source must define a module conforming `Importer` behaviour. |
| |
| The callbacks that need to be implemented: |
| |
| * `name/0` - Returns import source name as an atom. Example: `:universal_analytics`. |
| * `label/0` - Descriptive, display friendly name of the source. |
| Example: "Google Analytics". |
| * `email_template/0` - Name of the email template to use for notifications in |
| `PlausibleWeb.Email` (`import_success` and `import_failure`). The template |
| should have content customized for a particular source. |
| * `parse_args/1` - Receives Oban job arguments coming from `new_import/3`. Whatever |
| options were passed to `new_import/3` will be present in the input map with string |
| keys and values serialized to primitives. If, for instance `start_date: ~D[2024-01-03]` |
| is passed as an option, `parse_args/1` receives `%{..., "start_date" => "2024-01-03"}`. |
| The expectation is parsing the map values producing a keyword list of options to |
| pass to `import_data/2`. |
| * `import_data/2` - Receives site import struct and options produced by `parse_args/1`. |
| This is where all the import processing is done. The way the import is implemented |
| is entirely arbitrary except the requirement that the process as a whole must |
| by synchronous. The callback is expected to return either `:ok` or `{:ok, %{...}}` |
| on successful import or `{:error, ...}` on failure. The map in success tuple is |
| used for updating site import struct and is passed to `on_success/2` callback. |
| Please note that error tuple should be only returned on errors that can't be |
| recovered from. For transient errors, the import should throw an exception or |
| simply crash. The error tuple has an alternative `{error, reason, opts}` form, |
| where `opts` allow to skip purging imported data so far via `skip_purge?` flag |
| and skip marking the import as failed and notifying the user via `skip_mark_failed?` |
| flag. Both flags are booleans. |
| * `before_start/2` - Optional callback run right before scheduling import job. It's |
| expected to either return `{:ok, site_import}` for the import to proceed |
| or `{:error, ...}` tuple, which will be returned from `new_import/3` call. |
| The `site_import` can be altered or replaced at this stage. The second argument |
| are opts passed to `new_import/3`. |
| * `on_success/2` - Optional callback run once site import is completed. Receives map |
| returned from `import_data/2`. Expected to always return `:ok`. |
| * `on_failure/1` - Optional callback run when import job fails permanently. |
| |
| All sources must be added to the list in `Plausible.Imported.ImportSources`. |
| |
| In order to schedule a new import job using a given source, respective importer's |
| `new_import/3` function must be called. It accepts site, user who is doing the import |
| and any options necessary to carry out the import. |
| |
| There's an expectation that `start_date` and `end_date` are provided either as options |
| passed to `new_import/3` or data in map returned from `import_data/2`. If these parameters |
| are not provided, the import will eventually crash. These parameters define time range |
| of imported data which is in turn used for efficient querying. |
| |
| Logic running inside `import_data/2` is expected to populated all `imported_*` tables |
| in ClickHouse with `import_id` column set to site import's ID. |
| |
| Managing any configuration or authentication prior to running import is outside of |
| scope of importer logic and is expected to be implemented separately. |
| |
| ## Running import fully synchronously |
| |
| In case it's necessary to run the whole import job fully synchronously, the |
| `Plausible.Workers.ImportAnalytics` worker sends an `Oban.Notifier` message |
| on completion, failure or transient failure of the import. |
| |
| A basic usage scenario looks like this: |
| |
| ```elixir |
| {:ok, job} = Plausible.Imported.NoopImporter.new_import( |
| site, |
| user, |
| start_date: ~D[2005-01-01], |
| end_date: Date.utc_today(), |
| # this option is necessary to setup the calling process as listener |
| listen?: true |
| ) |
| |
| import_id = job.args[:import_id] |
| |
| receive do |
| {:notification, :analytics_imports_jobs, %{"event" => "complete", "import_id" => ^import_id}} -> |
| IO.puts("Job completed") |
| |
| {:notification, :analytics_imports_jobs, %{"event" => "transient_fail", "import_id" => ^import_id}} -> |
| IO.puts("Job failed transiently") |
| |
| {:notification, :analytics_imports_jobs, %{"event" => "fail", "import_id" => ^import_id}} -> |
| IO.puts("Job failed permanently") |
| after |
| 15_000 -> |
| IO.puts("Job didn't finish in 15 seconds") |
| end |
| ``` |
| |
| In a more realistic scenario, job scheduling will be done inside a GenServer process |
| like LiveView, where notifications can be listened for via `handle_info/2`. |
| """ |
|
|
| alias Plausible.Imported |
| alias Plausible.Imported.SiteImport |
| alias Plausible.Repo |
|
|
| @callback name() :: atom() |
| @callback label() :: String.t() |
| @callback email_template() :: String.t() |
| @callback parse_args(map()) :: Keyword.t() |
| @callback import_data(SiteImport.t(), Keyword.t()) :: |
| :ok | {:error, any()} | {:error, any(), Keyword.t()} |
| @callback before_start(SiteImport.t(), Keyword.t()) :: {:ok, SiteImport.t()} | {:error, any()} |
| @callback on_success(SiteImport.t(), map()) :: :ok |
| @callback on_failure(SiteImport.t()) :: :ok |
|
|
| defmacro __using__(_opts) do |
| quote do |
| @behaviour Plausible.Imported.Importer |
|
|
| @spec new_import(Plausible.Site.t(), Plausible.Auth.User.t(), Keyword.t()) :: |
| {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t() | :import_in_progress | any()} |
| def new_import(site, user, opts) do |
| Plausible.Imported.Importer.new_import(name(), site, user, opts, &before_start/2) |
| end |
|
|
| @doc false |
| @spec run_import(SiteImport.t(), map()) :: {:ok, SiteImport.t()} | {:error, :any} |
| def run_import(site_import, args) do |
| Plausible.Imported.Importer.run_import( |
| site_import, |
| args, |
| &parse_args/1, |
| &import_data/2, |
| &on_success/2 |
| ) |
| end |
|
|
| @doc false |
| @spec mark_failed(SiteImport.t()) :: SiteImport.t() |
| def mark_failed(site_import) do |
| site_import = |
| site_import |
| |> SiteImport.fail_changeset() |
| |> Repo.update!() |
|
|
| :ok = on_failure(site_import) |
|
|
| site_import |
| end |
|
|
| @impl true |
| def before_start(site_import, _opts), do: {:ok, site_import} |
|
|
| @impl true |
| def on_success(_site_import, _extra_data), do: :ok |
|
|
| @impl true |
| def on_failure(_site_import), do: :ok |
|
|
| defoverridable before_start: 2, on_success: 2, on_failure: 1 |
| end |
| end |
|
|
| @doc false |
| def new_import(source, site, user, opts, before_start_fun) do |
| import_params = |
| opts |
| |> Keyword.take([:label, :start_date, :end_date, :legacy]) |
| |> Keyword.put(:source, source) |
| |> Map.new() |
|
|
| Repo.transaction(fn -> |
| result = |
| site |
| |> SiteImport.create_changeset(user, import_params) |
| |> Repo.insert() |
|
|
| with {:ok, site_import} <- result, |
| {:ok, site_import} <- before_start_fun.(site_import, opts), |
| {:ok, job} <- schedule_job(site_import, opts) do |
| job |
| else |
| {:error, error} -> |
| Repo.rollback(error) |
| end |
| end) |
| end |
|
|
| @doc false |
| def run_import(site_import, args, parse_args_fun, import_fun, on_success_fun) do |
| site_import = |
| site_import |
| |> SiteImport.start_changeset() |
| |> Repo.update!() |
| |> Repo.preload(:site) |
|
|
| import_opts = parse_args_fun.(args) |
|
|
| case import_fun.(site_import, import_opts) do |
| :ok -> |
| {:ok, mark_complete(site_import, %{}, on_success_fun)} |
|
|
| {:ok, extra_data} -> |
| {:ok, mark_complete(site_import, extra_data, on_success_fun)} |
|
|
| {:error, error} -> |
| {:error, error, []} |
|
|
| {:error, error, opts} -> |
| {:error, error, opts} |
| end |
| end |
|
|
| @oban_channel :analytics_imports_jobs |
|
|
| @doc false |
| def notify(site_import, event) do |
| Oban.Notifier.notify(Oban, @oban_channel, %{ |
| "event" => event, |
| "import_id" => site_import.id, |
| "site_id" => site_import.site_id |
| }) |
| end |
|
|
| @doc """ |
| Allows to explicitly start listening for importer job notifications. |
| |
| Listener must explicitly filter out a subset of imports that apply to the given context. |
| """ |
| @spec listen() :: :ok |
| def listen() do |
| :ok = Oban.Notifier.listen([@oban_channel]) |
| end |
|
|
| defp schedule_job(site_import, opts) do |
| {listen?, opts} = Keyword.pop(opts, :listen?, false) |
| {job_opts, opts} = Keyword.pop(opts, :job_opts, []) |
|
|
| if listen? do |
| :ok = listen() |
| end |
|
|
| if not Imported.other_imports_in_progress?(site_import) do |
| opts |
| |> Keyword.put(:import_id, site_import.id) |
| |> Map.new() |
| |> Plausible.Workers.ImportAnalytics.new(job_opts) |
| |> Oban.insert() |
| else |
| {:error, :import_in_progress} |
| end |
| end |
|
|
| defp mark_complete(site_import, extra_data, on_success_fun) do |
| site_import = |
| site_import |
| |> SiteImport.complete_changeset(extra_data) |
| |> Repo.update!() |
|
|
| :ok = on_success_fun.(site_import, extra_data) |
|
|
| site_import |
| end |
| end |
|
|