Skip to content

Commit 1662eae

Browse files
authored
Add ability to make the FE trigger automatic dashboard refresh (#6442)
* implement automatic reload * single source of truth: pass meta tag from the BE * improve naming * add test * fix FE tests - extract fetchSuggestions into a separate module to break circular dependency - handle headers not existing in API response (no headers in tests) * increase version to test on staging * works, revert version increase * test x-api-version appearing in /query response headers * console log -> warn * move condition to maybeReload fn * reload only for idempotent requests * return effective api_version as minimum across the cluster * FE: reload only when effective version is greater than expected + tests * parse and stringify to add search param * ETS approach + default to 0 before first multicall response * fallback to 0 in effective_version instead * remove redundant start_link in test_helper * test increase version * revert increase
1 parent 63929d3 commit 1662eae

15 files changed

Lines changed: 331 additions & 35 deletions

File tree

assets/js/dashboard/api.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { serializeApiFilters } from './util/filters'
77
import * as url from './util/url'
88
import { MainGraphResponse } from './stats/graph/fetch-main-graph'
99
import { CsvExportRequestBody } from './stats/csv-export/csv-export-body'
10+
import { maybeReloadForApiVersion } from './util/url-search-params'
1011

1112
let abortController = new AbortController()
1213
let SHARED_LINK_AUTH: null | string = null
@@ -148,7 +149,14 @@ async function throwApiErrorIfNotOk(response: Response) {
148149
}
149150
}
150151

151-
async function handleApiResponse(response: Response) {
152+
async function handleApiResponse(
153+
response: Response,
154+
opts: Record<'idempotent', boolean> = { idempotent: true }
155+
) {
156+
if (opts.idempotent) {
157+
maybeReloadForApiVersion(window.location, response.headers)
158+
}
159+
152160
await throwApiErrorIfNotOk(response)
153161
return response.json()
154162
}
@@ -262,5 +270,5 @@ export const mutation = async <
262270
body: fetchOptions.body,
263271
signal: abortController.signal
264272
})
265-
return handleApiResponse(response)
273+
return handleApiResponse(response, { idempotent: false })
266274
}

assets/js/dashboard/stats/modals/filter-modal-props-row.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import { apiPath } from '../../util/url'
88
import {
99
EVENT_PROPS_PREFIX,
1010
FILTER_OPERATIONS,
11-
fetchSuggestions,
1211
getPropertyKeyFromFilterKey,
1312
isFreeChoiceFilterOperation
1413
} from '../../util/filters'
14+
import { fetchSuggestions } from '../../util/fetch-suggestions'
1515
import { useDashboardStateContext } from '../../dashboard-state-context'
1616
import { useSiteContext } from '../../site-context'
1717

assets/js/dashboard/stats/modals/filter-modal-row.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import Combobox from '../../components/combobox'
77

88
import {
99
FILTER_OPERATIONS,
10-
fetchSuggestions,
1110
isFreeChoiceFilterOperation,
1211
getLabel,
1312
formattedFilters
1413
} from '../../util/filters'
14+
import { fetchSuggestions } from '../../util/fetch-suggestions'
1515
import { apiPath } from '../../util/url'
1616
import { useDashboardStateContext } from '../../dashboard-state-context'
1717
import { useSiteContext } from '../../site-context'
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as api from '../api'
2+
import { DashboardState, Filter } from '../dashboard-state'
3+
import { replaceFilterByPrefix, omitFiltersByKeyPrefix } from './filters'
4+
5+
export function fetchSuggestions(
6+
apiPath: string,
7+
dashboardState: DashboardState,
8+
input: string,
9+
additionalFilter?: Filter
10+
) {
11+
const updatedQuery = queryForSuggestions(dashboardState, additionalFilter)
12+
return api.get(apiPath, updatedQuery, { q: input.trim() })
13+
}
14+
15+
function queryForSuggestions(
16+
dashboardState: DashboardState,
17+
additionalFilter?: Filter
18+
): DashboardState {
19+
let filters = dashboardState.filters
20+
if (additionalFilter) {
21+
const [_operation, filterKey, clauses] = additionalFilter
22+
23+
// For suggestions, we remove already-applied filter with same key from dashboardState and add new filter (if feasible)
24+
if (clauses.length > 0) {
25+
filters = replaceFilterByPrefix(
26+
dashboardState,
27+
filterKey,
28+
additionalFilter
29+
)
30+
} else {
31+
filters = omitFiltersByKeyPrefix(dashboardState, filterKey)
32+
}
33+
}
34+
return { ...dashboardState, filters }
35+
}

assets/js/dashboard/util/filters.js

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import * as api from '../api'
21
import { formatSegmentIdAsLabelKey } from '../filtering/segments'
32

43
export const FILTER_MODAL_TO_FILTER_GROUP = {
@@ -95,7 +94,7 @@ const hasDimensionPrefix =
9594
([_operation, dimension, _clauses]) =>
9695
dimension.startsWith(prefix)
9796

98-
function omitFiltersByKeyPrefix(dashboardState, prefix) {
97+
export function omitFiltersByKeyPrefix(dashboardState, prefix) {
9998
return dashboardState.filters.filter(
10099
([_operation, filterKey, _clauses]) => !filterKey.startsWith(prefix)
101100
)
@@ -279,35 +278,6 @@ function remapToApiFilter([operation, filterKey, clauses, ...modifiers]) {
279278
}
280279
}
281280

282-
export function fetchSuggestions(
283-
apiPath,
284-
dashboardState,
285-
input,
286-
additionalFilter
287-
) {
288-
const updatedQuery = queryForSuggestions(dashboardState, additionalFilter)
289-
return api.get(apiPath, updatedQuery, { q: input.trim() })
290-
}
291-
292-
function queryForSuggestions(dashboardState, additionalFilter) {
293-
let filters = dashboardState.filters
294-
if (additionalFilter) {
295-
const [_operation, filterKey, clauses] = additionalFilter
296-
297-
// For suggestions, we remove already-applied filter with same key from dashboardState and add new filter (if feasible)
298-
if (clauses.length > 0) {
299-
filters = replaceFilterByPrefix(
300-
dashboardState,
301-
filterKey,
302-
additionalFilter
303-
)
304-
} else {
305-
filters = omitFiltersByKeyPrefix(dashboardState, filterKey)
306-
}
307-
}
308-
return { ...dashboardState, filters }
309-
}
310-
311281
export function getFilterGroup([_operation, filterKey, _clauses]) {
312282
return filterKey.startsWith(EVENT_PROPS_PREFIX) ? 'props' : filterKey
313283
}

assets/js/dashboard/util/url-search-params.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getSearchWithEnforcedSegment,
55
isSearchEntryDefined,
66
maybeGetLatestReadableSearch,
7+
maybeReloadForApiVersion,
78
parseFilter,
89
parseLabelsEntry,
910
parseSearch,
@@ -259,3 +260,64 @@ describe(`${getSearchWithEnforcedSegment.name}`, () => {
259260
).toEqual(expectedUpdatedSearch)
260261
})
261262
})
263+
264+
describe(`${maybeReloadForApiVersion.name}`, () => {
265+
const dashboardPathname = '/example.com'
266+
267+
type MockWindowLocation = Location & { replace: jest.Mock }
268+
269+
function makeLocation(search: string): MockWindowLocation {
270+
return {
271+
pathname: dashboardPathname,
272+
search,
273+
hash: '',
274+
replace: jest.fn()
275+
} as unknown as MockWindowLocation
276+
}
277+
278+
function makeHeaders(version: string | null): Headers {
279+
const headers = new Headers()
280+
if (version !== null) headers.set('x-api-version', version)
281+
return headers
282+
}
283+
284+
it('reloads when effective API version is greater than expected', () => {
285+
const location = makeLocation('')
286+
maybeReloadForApiVersion(location, makeHeaders('1'))
287+
expect(location.replace).toHaveBeenCalledWith(
288+
`${dashboardPathname}?api_version_reloaded=1`
289+
)
290+
})
291+
292+
it('does not reload when effective API version equals expected', () => {
293+
const location = makeLocation('')
294+
maybeReloadForApiVersion(location, makeHeaders('0'))
295+
expect(location.replace).not.toHaveBeenCalled()
296+
})
297+
298+
it('does not reload when effective API version is less than expected (FE loaded from newer node, cluster not fully updated)', () => {
299+
const location = makeLocation('')
300+
maybeReloadForApiVersion(location, makeHeaders('-1'))
301+
expect(location.replace).not.toHaveBeenCalled()
302+
})
303+
304+
it('does not reload when x-api-version header is absent', () => {
305+
const location = makeLocation('')
306+
maybeReloadForApiVersion(location, makeHeaders(null))
307+
expect(location.replace).not.toHaveBeenCalled()
308+
})
309+
310+
it('does not reload when already reloaded for this version', () => {
311+
const location = makeLocation('?api_version_reloaded=1')
312+
maybeReloadForApiVersion(location, makeHeaders('1'))
313+
expect(location.replace).not.toHaveBeenCalled()
314+
})
315+
316+
it('reloads again if a newer version is detected after a previous reload', () => {
317+
const location = makeLocation('?api_version_reloaded=1')
318+
maybeReloadForApiVersion(location, makeHeaders('2'))
319+
expect(location.replace).toHaveBeenCalledWith(
320+
`${dashboardPathname}?api_version_reloaded=2`
321+
)
322+
})
323+
})

assets/js/dashboard/util/url-search-params.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,69 @@ const LABEL_URL_PARAM_NAME = 'l'
1919

2020
const REDIRECTED_SEARCH_PARAM_NAME = 'r'
2121

22+
const API_VERSION_RELOAD_PARAM_NAME = 'api_version_reloaded'
23+
24+
const EXPECTED_API_VERSION = parseInt(
25+
document
26+
.querySelector('meta[name="x-api-version"]')
27+
?.getAttribute('content') ?? '0',
28+
10
29+
)
30+
31+
/**
32+
* Navigates to the current URL with `api_version_reloaded=<currentApiVersion>`
33+
* appended, using `location.replace` so the pre-reload entry is not kept in
34+
* browser history.
35+
*
36+
* Returns early without navigating if:
37+
*
38+
* - the x-plausible-version response header is not present
39+
* - the expected version matches the actual version
40+
* - the version is already present in search params
41+
*
42+
* The latter prevents an infinite reload loop when the versions are
43+
* permanently out of sync.
44+
*
45+
* BE: lib/plausible_web/plugs/internal_stats_api_version.ex
46+
*/
47+
export function maybeReloadForApiVersion(
48+
windowLocation: Location,
49+
responseHeaders: Headers
50+
) {
51+
const currentApiVersion = getCurrentApiVersion(responseHeaders)
52+
const params = new URLSearchParams(windowLocation.search)
53+
54+
if (
55+
currentApiVersion === null ||
56+
currentApiVersion <= EXPECTED_API_VERSION ||
57+
params.get(API_VERSION_RELOAD_PARAM_NAME) === currentApiVersion.toString()
58+
) {
59+
return
60+
}
61+
62+
console.warn('API version mismatch detected, reloading...')
63+
64+
const newSearch = searchWithApiVersionReload(
65+
windowLocation.search,
66+
currentApiVersion.toString()
67+
)
68+
windowLocation.replace(
69+
`${windowLocation.pathname}${newSearch}${windowLocation.hash}`
70+
)
71+
}
72+
73+
function getCurrentApiVersion(responseHeaders: Headers): number | null {
74+
const versionString = responseHeaders?.get('x-api-version')
75+
return versionString ? parseInt(versionString, 10) : null
76+
}
77+
78+
function searchWithApiVersionReload(search: string, value: string): string {
79+
return stringifySearch({
80+
...parseSearch(search),
81+
[API_VERSION_RELOAD_PARAM_NAME]: value
82+
})
83+
}
84+
2285
/**
2386
* This function is able to serialize for URL simple params @see serializeSimpleSearchEntry as well
2487
* two complex params, labels and filters.

lib/plausible/application.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ defmodule Plausible.Application do
3131
children =
3232
[
3333
cluster,
34+
Plausible.InternalStatsApiVersion,
3435
{PartitionSupervisor,
3536
child_spec: Task.Supervisor, name: Plausible.UserAgentParseTaskSupervisor},
3637
Plausible.Session.BalancerSupervisor,
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
defmodule Plausible.InternalStatsApiVersion do
2+
@moduledoc """
3+
Tracks the effective internal stats API version across the cluster.
4+
5+
Increment `@api_version` when deploying a change that breaks dashboards
6+
already loaded from a previous deployment. The FE, upon detecting a
7+
version mismatch, reloads the page to fetch the new dashboard code.
8+
9+
Each app node has `@api_version` compiled in. The effective version served
10+
to clients is the minimum across all connected nodes, fetched via
11+
`:rpc.multicall` and refreshed every 30 seconds. This means the version
12+
only advances once every node in a rolling deploy is running the new code,
13+
avoiding repeated dashboard reloads during the deployment window.
14+
"""
15+
use GenServer
16+
17+
@api_version 0
18+
19+
@refresh_interval :timer.seconds(30)
20+
21+
@spec api_version() :: non_neg_integer()
22+
def api_version, do: @api_version
23+
24+
@spec effective_version() :: non_neg_integer()
25+
def effective_version() do
26+
# Use 0 as a placeholder version until the first multicall completes.
27+
# The FE only reloads when the received version exceeds its compiled-in
28+
# expectation, so 0 is always safe regardless of current @api_version.
29+
case :ets.lookup(__MODULE__, :version) do
30+
[{:version, v}] -> v
31+
_ -> 0
32+
end
33+
end
34+
35+
def start_link(opts \\ []) do
36+
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
37+
end
38+
39+
@impl GenServer
40+
def init(_opts) do
41+
__MODULE__ =
42+
:ets.new(__MODULE__, [
43+
:named_table,
44+
:set,
45+
:protected,
46+
{:read_concurrency, true}
47+
])
48+
49+
{:ok, nil, {:continue, :fetch}}
50+
end
51+
52+
@impl GenServer
53+
def handle_continue(:fetch, state) do
54+
Process.send_after(self(), :refresh, @refresh_interval)
55+
:ets.insert(__MODULE__, {:version, fetch_cluster_min()})
56+
{:noreply, state}
57+
end
58+
59+
@impl GenServer
60+
def handle_info(:refresh, state) do
61+
Process.send_after(self(), :refresh, @refresh_interval)
62+
:ets.insert(__MODULE__, {:version, fetch_cluster_min()})
63+
{:noreply, state}
64+
end
65+
66+
defp fetch_cluster_min() do
67+
{results, _bad_nodes} = :rpc.multicall(__MODULE__, :api_version, [], :timer.seconds(5))
68+
cluster_min(results)
69+
end
70+
71+
def cluster_min(results) do
72+
case Enum.filter(results, &is_integer/1) do
73+
[] -> @api_version
74+
versions -> Enum.min(versions)
75+
end
76+
end
77+
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
defmodule PlausibleWeb.Plugs.InternalStatsApiVersion do
2+
@moduledoc """
3+
Adds the `x-api-version` response header to all internal
4+
stats API responses. See `Plausible.InternalStatsApiVersion`
5+
for version tracking and rollout logic.
6+
"""
7+
@behaviour Plug
8+
import Plug.Conn
9+
10+
@impl true
11+
def init(opts), do: opts
12+
13+
@impl true
14+
def call(conn, _opts) do
15+
version = Plausible.InternalStatsApiVersion.effective_version()
16+
put_resp_header(conn, "x-api-version", to_string(version))
17+
end
18+
end

0 commit comments

Comments
 (0)