-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Add ability to make the FE trigger automatic dashboard refresh #6442
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5ccbded
39daaf1
ea4de8d
e4605f4
a9a3686
a7dc873
b127ccf
e6db30c
7508a67
9a7d8fe
80f3c80
cb380a0
cb765e0
5cf4782
b4dc7c2
d03eb9b
6c09655
5644418
b383b1a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import * as api from '../api' | ||
| import { DashboardState, Filter } from '../dashboard-state' | ||
| import { replaceFilterByPrefix, omitFiltersByKeyPrefix } from './filters' | ||
|
|
||
| export function fetchSuggestions( | ||
| apiPath: string, | ||
| dashboardState: DashboardState, | ||
| input: string, | ||
| additionalFilter?: Filter | ||
| ) { | ||
| const updatedQuery = queryForSuggestions(dashboardState, additionalFilter) | ||
| return api.get(apiPath, updatedQuery, { q: input.trim() }) | ||
| } | ||
|
|
||
| function queryForSuggestions( | ||
| dashboardState: DashboardState, | ||
| additionalFilter?: Filter | ||
| ): DashboardState { | ||
| let filters = dashboardState.filters | ||
| if (additionalFilter) { | ||
| const [_operation, filterKey, clauses] = additionalFilter | ||
|
|
||
| // For suggestions, we remove already-applied filter with same key from dashboardState and add new filter (if feasible) | ||
| if (clauses.length > 0) { | ||
| filters = replaceFilterByPrefix( | ||
| dashboardState, | ||
| filterKey, | ||
| additionalFilter | ||
| ) | ||
| } else { | ||
| filters = omitFiltersByKeyPrefix(dashboardState, filterKey) | ||
| } | ||
| } | ||
| return { ...dashboardState, filters } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,69 @@ const LABEL_URL_PARAM_NAME = 'l' | |
|
|
||
| const REDIRECTED_SEARCH_PARAM_NAME = 'r' | ||
|
|
||
| const API_VERSION_RELOAD_PARAM_NAME = 'api_version_reloaded' | ||
|
|
||
| const EXPECTED_API_VERSION = parseInt( | ||
| document | ||
| .querySelector('meta[name="x-api-version"]') | ||
| ?.getAttribute('content') ?? '0', | ||
| 10 | ||
| ) | ||
|
|
||
| /** | ||
| * Navigates to the current URL with `api_version_reloaded=<currentApiVersion>` | ||
| * appended, using `location.replace` so the pre-reload entry is not kept in | ||
| * browser history. | ||
| * | ||
| * Returns early without navigating if: | ||
| * | ||
| * - the x-plausible-version response header is not present | ||
| * - the expected version matches the actual version | ||
| * - the version is already present in search params | ||
| * | ||
| * The latter prevents an infinite reload loop when the versions are | ||
| * permanently out of sync. | ||
| * | ||
| * BE: lib/plausible_web/plugs/internal_stats_api_version.ex | ||
| */ | ||
| export function maybeReloadForApiVersion( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue, blocking: I think this must be unit tested quite extensively. The FE makes multiple requests for data concurrently. The requests may hit different instances of the BE. During rolling deploys, the BE instances may report different API versions. What should happen? I suggest we spec it out with a jest test suite that includes the concurrent requests during rolling deploys scenario. I suspect we may need to set a flag that we're about to update to have deterministic behavior, e.g. at module level,
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Uff, yeah good catch! I feel like this is now turning into an infrastructure problem. So basically, a dashboard reload is useless until there's old code still around on at least one app server. We definitely don't want the dashboard to keep reloading itself until our deployment finishes. I guess the version bump should happen separate from the code change then. I'm now thinking about storing the version in Postgres and bumping it via remote shell once all deployments have finished, maybe?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've landed on Basically, the idea is that when we start rolling out new code, the effective version will be the old one for as long as there's at least one app node still at that version. This guarantees that all nodes have updated once we trigger the automatic refresh. However, the client can still (re)load the dashboard manually while deployment is in progress, meaning they'd get the new FE code with requests served by the old BE. Although there's a version mismatch there, we don't reload the page because the frontend API version is ahead. Basically the frontend is in the correct state, and just waiting for the backend to update. |
||
| windowLocation: Location, | ||
| responseHeaders: Headers | ||
| ) { | ||
| const currentApiVersion = getCurrentApiVersion(responseHeaders) | ||
| const params = new URLSearchParams(windowLocation.search) | ||
|
|
||
| if ( | ||
| currentApiVersion === null || | ||
| currentApiVersion <= EXPECTED_API_VERSION || | ||
| params.get(API_VERSION_RELOAD_PARAM_NAME) === currentApiVersion.toString() | ||
| ) { | ||
| return | ||
| } | ||
|
|
||
| console.warn('API version mismatch detected, reloading...') | ||
|
|
||
| const newSearch = searchWithApiVersionReload( | ||
| windowLocation.search, | ||
| currentApiVersion.toString() | ||
| ) | ||
| windowLocation.replace( | ||
| `${windowLocation.pathname}${newSearch}${windowLocation.hash}` | ||
| ) | ||
| } | ||
|
|
||
| function getCurrentApiVersion(responseHeaders: Headers): number | null { | ||
| const versionString = responseHeaders?.get('x-api-version') | ||
| return versionString ? parseInt(versionString, 10) : null | ||
| } | ||
|
|
||
| function searchWithApiVersionReload(search: string, value: string): string { | ||
| return stringifySearch({ | ||
| ...parseSearch(search), | ||
| [API_VERSION_RELOAD_PARAM_NAME]: value | ||
| }) | ||
| } | ||
|
|
||
| /** | ||
| * This function is able to serialize for URL simple params @see serializeSimpleSearchEntry as well | ||
| * two complex params, labels and filters. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| defmodule Plausible.InternalStatsApiVersion do | ||
| @moduledoc """ | ||
| Tracks the effective internal stats API version across the cluster. | ||
|
|
||
| Increment `@api_version` when deploying a change that breaks dashboards | ||
| already loaded from a previous deployment. The FE, upon detecting a | ||
| version mismatch, reloads the page to fetch the new dashboard code. | ||
|
|
||
| Each app node has `@api_version` compiled in. The effective version served | ||
| to clients is the minimum across all connected nodes, fetched via | ||
| `:rpc.multicall` and refreshed every 30 seconds. This means the version | ||
| only advances once every node in a rolling deploy is running the new code, | ||
| avoiding repeated dashboard reloads during the deployment window. | ||
| """ | ||
| use GenServer | ||
|
|
||
| @api_version 0 | ||
|
|
||
| @refresh_interval :timer.seconds(30) | ||
|
|
||
| @spec api_version() :: non_neg_integer() | ||
| def api_version, do: @api_version | ||
|
|
||
| @spec effective_version() :: non_neg_integer() | ||
| def effective_version() do | ||
| # Use 0 as a placeholder version until the first multicall completes. | ||
| # The FE only reloads when the received version exceeds its compiled-in | ||
| # expectation, so 0 is always safe regardless of current @api_version. | ||
| case :ets.lookup(__MODULE__, :version) do | ||
| [{:version, v}] -> v | ||
| _ -> 0 | ||
| end | ||
| end | ||
|
|
||
| def start_link(opts \\ []) do | ||
| GenServer.start_link(__MODULE__, opts, name: __MODULE__) | ||
| end | ||
|
|
||
| @impl GenServer | ||
| def init(_opts) do | ||
| __MODULE__ = | ||
| :ets.new(__MODULE__, [ | ||
| :named_table, | ||
| :set, | ||
| :protected, | ||
| {:read_concurrency, true} | ||
| ]) | ||
|
|
||
| {:ok, nil, {:continue, :fetch}} | ||
| end | ||
|
|
||
| @impl GenServer | ||
| def handle_continue(:fetch, state) do | ||
| Process.send_after(self(), :refresh, @refresh_interval) | ||
| :ets.insert(__MODULE__, {:version, fetch_cluster_min()}) | ||
| {:noreply, state} | ||
| end | ||
|
|
||
| @impl GenServer | ||
| def handle_info(:refresh, state) do | ||
| Process.send_after(self(), :refresh, @refresh_interval) | ||
| :ets.insert(__MODULE__, {:version, fetch_cluster_min()}) | ||
| {:noreply, state} | ||
| end | ||
|
|
||
| defp fetch_cluster_min() do | ||
| {results, _bad_nodes} = :rpc.multicall(__MODULE__, :api_version, [], :timer.seconds(5)) | ||
| cluster_min(results) | ||
| end | ||
|
|
||
| def cluster_min(results) do | ||
| case Enum.filter(results, &is_integer/1) do | ||
| [] -> @api_version | ||
| versions -> Enum.min(versions) | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| defmodule PlausibleWeb.Plugs.InternalStatsApiVersion do | ||
| @moduledoc """ | ||
| Adds the `x-api-version` response header to all internal | ||
| stats API responses. See `Plausible.InternalStatsApiVersion` | ||
| for version tracking and rollout logic. | ||
| """ | ||
| @behaviour Plug | ||
| import Plug.Conn | ||
|
|
||
| @impl true | ||
| def init(opts), do: opts | ||
|
|
||
| @impl true | ||
| def call(conn, _opts) do | ||
| version = Plausible.InternalStatsApiVersion.effective_version() | ||
| put_resp_header(conn, "x-api-version", to_string(version)) | ||
| end | ||
| end |
Uh oh!
There was an error while loading. Please reload this page.