diff --git a/assets/js/dashboard/api.ts b/assets/js/dashboard/api.ts index c2e748b8e982..e258f72065b1 100644 --- a/assets/js/dashboard/api.ts +++ b/assets/js/dashboard/api.ts @@ -7,6 +7,7 @@ import { serializeApiFilters } from './util/filters' import * as url from './util/url' import { MainGraphResponse } from './stats/graph/fetch-main-graph' import { CsvExportRequestBody } from './stats/csv-export/csv-export-body' +import { maybeReloadForApiVersion } from './util/url-search-params' let abortController = new AbortController() let SHARED_LINK_AUTH: null | string = null @@ -148,7 +149,14 @@ async function throwApiErrorIfNotOk(response: Response) { } } -async function handleApiResponse(response: Response) { +async function handleApiResponse( + response: Response, + opts: Record<'idempotent', boolean> = { idempotent: true } +) { + if (opts.idempotent) { + maybeReloadForApiVersion(window.location, response.headers) + } + await throwApiErrorIfNotOk(response) return response.json() } @@ -262,5 +270,5 @@ export const mutation = async < body: fetchOptions.body, signal: abortController.signal }) - return handleApiResponse(response) + return handleApiResponse(response, { idempotent: false }) } diff --git a/assets/js/dashboard/stats/modals/filter-modal-props-row.js b/assets/js/dashboard/stats/modals/filter-modal-props-row.js index 4dc73eb4318a..3912beb37486 100644 --- a/assets/js/dashboard/stats/modals/filter-modal-props-row.js +++ b/assets/js/dashboard/stats/modals/filter-modal-props-row.js @@ -8,10 +8,10 @@ import { apiPath } from '../../util/url' import { EVENT_PROPS_PREFIX, FILTER_OPERATIONS, - fetchSuggestions, getPropertyKeyFromFilterKey, isFreeChoiceFilterOperation } from '../../util/filters' +import { fetchSuggestions } from '../../util/fetch-suggestions' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' diff --git a/assets/js/dashboard/stats/modals/filter-modal-row.js b/assets/js/dashboard/stats/modals/filter-modal-row.js index 681b41737c66..8759dba7bb44 100644 --- a/assets/js/dashboard/stats/modals/filter-modal-row.js +++ b/assets/js/dashboard/stats/modals/filter-modal-row.js @@ -7,11 +7,11 @@ import Combobox from '../../components/combobox' import { FILTER_OPERATIONS, - fetchSuggestions, isFreeChoiceFilterOperation, getLabel, formattedFilters } from '../../util/filters' +import { fetchSuggestions } from '../../util/fetch-suggestions' import { apiPath } from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' diff --git a/assets/js/dashboard/util/fetch-suggestions.ts b/assets/js/dashboard/util/fetch-suggestions.ts new file mode 100644 index 000000000000..e42e624fdd90 --- /dev/null +++ b/assets/js/dashboard/util/fetch-suggestions.ts @@ -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 } +} diff --git a/assets/js/dashboard/util/filters.js b/assets/js/dashboard/util/filters.js index 22f34798c696..dc5d72fada37 100644 --- a/assets/js/dashboard/util/filters.js +++ b/assets/js/dashboard/util/filters.js @@ -1,4 +1,3 @@ -import * as api from '../api' import { formatSegmentIdAsLabelKey } from '../filtering/segments' export const FILTER_MODAL_TO_FILTER_GROUP = { @@ -95,7 +94,7 @@ const hasDimensionPrefix = ([_operation, dimension, _clauses]) => dimension.startsWith(prefix) -function omitFiltersByKeyPrefix(dashboardState, prefix) { +export function omitFiltersByKeyPrefix(dashboardState, prefix) { return dashboardState.filters.filter( ([_operation, filterKey, _clauses]) => !filterKey.startsWith(prefix) ) @@ -279,35 +278,6 @@ function remapToApiFilter([operation, filterKey, clauses, ...modifiers]) { } } -export function fetchSuggestions( - apiPath, - dashboardState, - input, - additionalFilter -) { - const updatedQuery = queryForSuggestions(dashboardState, additionalFilter) - return api.get(apiPath, updatedQuery, { q: input.trim() }) -} - -function queryForSuggestions(dashboardState, additionalFilter) { - 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 } -} - export function getFilterGroup([_operation, filterKey, _clauses]) { return filterKey.startsWith(EVENT_PROPS_PREFIX) ? 'props' : filterKey } diff --git a/assets/js/dashboard/util/url-search-params.test.ts b/assets/js/dashboard/util/url-search-params.test.ts index cab2c86f0a78..7de042aee116 100644 --- a/assets/js/dashboard/util/url-search-params.test.ts +++ b/assets/js/dashboard/util/url-search-params.test.ts @@ -4,6 +4,7 @@ import { getSearchWithEnforcedSegment, isSearchEntryDefined, maybeGetLatestReadableSearch, + maybeReloadForApiVersion, parseFilter, parseLabelsEntry, parseSearch, @@ -259,3 +260,64 @@ describe(`${getSearchWithEnforcedSegment.name}`, () => { ).toEqual(expectedUpdatedSearch) }) }) + +describe(`${maybeReloadForApiVersion.name}`, () => { + const dashboardPathname = '/example.com' + + type MockWindowLocation = Location & { replace: jest.Mock } + + function makeLocation(search: string): MockWindowLocation { + return { + pathname: dashboardPathname, + search, + hash: '', + replace: jest.fn() + } as unknown as MockWindowLocation + } + + function makeHeaders(version: string | null): Headers { + const headers = new Headers() + if (version !== null) headers.set('x-api-version', version) + return headers + } + + it('reloads when effective API version is greater than expected', () => { + const location = makeLocation('') + maybeReloadForApiVersion(location, makeHeaders('1')) + expect(location.replace).toHaveBeenCalledWith( + `${dashboardPathname}?api_version_reloaded=1` + ) + }) + + it('does not reload when effective API version equals expected', () => { + const location = makeLocation('') + maybeReloadForApiVersion(location, makeHeaders('0')) + expect(location.replace).not.toHaveBeenCalled() + }) + + it('does not reload when effective API version is less than expected (FE loaded from newer node, cluster not fully updated)', () => { + const location = makeLocation('') + maybeReloadForApiVersion(location, makeHeaders('-1')) + expect(location.replace).not.toHaveBeenCalled() + }) + + it('does not reload when x-api-version header is absent', () => { + const location = makeLocation('') + maybeReloadForApiVersion(location, makeHeaders(null)) + expect(location.replace).not.toHaveBeenCalled() + }) + + it('does not reload when already reloaded for this version', () => { + const location = makeLocation('?api_version_reloaded=1') + maybeReloadForApiVersion(location, makeHeaders('1')) + expect(location.replace).not.toHaveBeenCalled() + }) + + it('reloads again if a newer version is detected after a previous reload', () => { + const location = makeLocation('?api_version_reloaded=1') + maybeReloadForApiVersion(location, makeHeaders('2')) + expect(location.replace).toHaveBeenCalledWith( + `${dashboardPathname}?api_version_reloaded=2` + ) + }) +}) diff --git a/assets/js/dashboard/util/url-search-params.ts b/assets/js/dashboard/util/url-search-params.ts index da07588e5875..e273507f0c64 100644 --- a/assets/js/dashboard/util/url-search-params.ts +++ b/assets/js/dashboard/util/url-search-params.ts @@ -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=` + * 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( + 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. diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex index 973a670bad22..be485d3af971 100644 --- a/lib/plausible/application.ex +++ b/lib/plausible/application.ex @@ -31,6 +31,7 @@ defmodule Plausible.Application do children = [ cluster, + Plausible.InternalStatsApiVersion, {PartitionSupervisor, child_spec: Task.Supervisor, name: Plausible.UserAgentParseTaskSupervisor}, Plausible.Session.BalancerSupervisor, diff --git a/lib/plausible/internal_stats_api_version.ex b/lib/plausible/internal_stats_api_version.ex new file mode 100644 index 000000000000..98b1fcf5355d --- /dev/null +++ b/lib/plausible/internal_stats_api_version.ex @@ -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 diff --git a/lib/plausible_web/plugs/internal_stats_api_version.ex b/lib/plausible_web/plugs/internal_stats_api_version.ex new file mode 100644 index 000000000000..104d901a0a20 --- /dev/null +++ b/lib/plausible_web/plugs/internal_stats_api_version.ex @@ -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 diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 71f4db39e9f0..a76743397b28 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -74,6 +74,7 @@ defmodule PlausibleWeb.Router do plug PlausibleWeb.AuthPlug plug PlausibleWeb.Plugs.AuthorizeSiteAccess plug PlausibleWeb.Plugs.NoRobots + plug PlausibleWeb.Plugs.InternalStatsApiVersion end pipeline :docs_stats_api do diff --git a/lib/plausible_web/templates/layout/app.html.heex b/lib/plausible_web/templates/layout/app.html.heex index dc217ba23fa9..cc77e8a4ee9b 100644 --- a/lib/plausible_web/templates/layout/app.html.heex +++ b/lib/plausible_web/templates/layout/app.html.heex @@ -13,6 +13,10 @@ <% end %> + diff --git a/test/plausible/internal_stats_api_version_test.exs b/test/plausible/internal_stats_api_version_test.exs new file mode 100644 index 000000000000..752808b42988 --- /dev/null +++ b/test/plausible/internal_stats_api_version_test.exs @@ -0,0 +1,39 @@ +defmodule Plausible.InternalStatsApiVersionTest do + use ExUnit.Case, async: true + + alias Plausible.InternalStatsApiVersion + + describe "api_version/0" do + test "returns a non-negative integer" do + assert is_integer(InternalStatsApiVersion.api_version()) + assert InternalStatsApiVersion.api_version() >= 0 + end + end + + describe "effective_version/0" do + test "equals api_version in single-node setup" do + assert InternalStatsApiVersion.effective_version() == + InternalStatsApiVersion.api_version() + end + end + + describe "cluster_min/1" do + test "returns the minimum integer from results" do + assert InternalStatsApiVersion.cluster_min([3, 2, 2]) == 2 + end + + test "ignores non-integer results" do + assert InternalStatsApiVersion.cluster_min([{:badrpc, :timeout}, 2, 3]) == 2 + end + + test "falls back to local api_version when all results are non-integer" do + assert InternalStatsApiVersion.cluster_min([{:badrpc, :timeout}]) == + InternalStatsApiVersion.api_version() + end + + test "falls back to local api_version for empty results" do + assert InternalStatsApiVersion.cluster_min([]) == + InternalStatsApiVersion.api_version() + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/query_controller_test.exs b/test/plausible_web/controllers/api/stats_controller/query_controller_test.exs index 69cf7fde7cf2..f8473641a51b 100644 --- a/test/plausible_web/controllers/api/stats_controller/query_controller_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/query_controller_test.exs @@ -117,6 +117,19 @@ defmodule PlausibleWeb.Api.InternalController.QueryTest do } end + test "adds x-api-version response header", %{conn: conn, site: site} do + conn = + post(conn, "/api/stats/#{URI.encode(site.domain)}/query", %{ + "metrics" => ["pageviews"], + "date_range" => "all", + "filters" => [] + }) + + assert get_resp_header(conn, "x-api-version") == [ + to_string(Plausible.InternalStatsApiVersion.effective_version()) + ] + end + test "returns aggregated metrics", %{conn: conn, site: site} do populate_stats(site, [ build(:pageview, user_id: 5, timestamp: ~N[2021-01-01 00:00:00]), diff --git a/test/plausible_web/controllers/stats_controller_test.exs b/test/plausible_web/controllers/stats_controller_test.exs index dc9f858d034b..3d07cb667431 100644 --- a/test/plausible_web/controllers/stats_controller_test.exs +++ b/test/plausible_web/controllers/stats_controller_test.exs @@ -37,6 +37,11 @@ defmodule PlausibleWeb.StatsControllerTest do |> find("meta[name=robots]") |> text_of_attr("content") + assert to_string(Plausible.InternalStatsApiVersion.api_version()) == + resp + |> find("meta[name=x-api-version]") + |> text_of_attr("content") + assert text_of_element(resp, "title") == "Plausible ยท #{site.domain}" end