This is an automated email from the ASF dual-hosted git repository.
kayx23 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-website.git
The following commit(s) were added to refs/heads/master by this push:
new 8dd5b623c4d fix: count every page view on Astro and Docusaurus pages
(#2152)
8dd5b623c4d is described below
commit 8dd5b623c4dbeb04c34b9743836d260120c2223a
Author: Zhi Yuan Ju <[email protected]>
AuthorDate: Fri Nov 13 09:31:37 2026 +0800
fix: count every page view on Astro and Docusaurus pages (#2152)
* fix: restore Matomo tracking on Astro pages
* fix: count every page view on Astro and Docusaurus pages
Share one Matomo snippet between the Docusaurus template and the Astro
layout so site ID, hostname gating and landing-view pinning cannot
drift. Track Docusaurus client-side route changes, which previously
never sent a page view. Assert in CI that every published page carries
exactly one tracker.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin
<158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin
<158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.github/workflows/deploy.yml | 22 ++++++
blog/en/docusaurus.config.js | 3 +-
blog/zh/docusaurus.config.js | 3 +-
config/ssrTemplate.js | 32 ++-------
doc/docusaurus.config.js | 3 +-
next/MIGRATION.md | 4 +-
next/README.md | 4 +-
next/src/layouts/Base.astro | 4 +-
next/tests/e2e/analytics.spec.mjs | 114 ++++++++++++++++++++++++++++++++
next/tests/e2e/ecosystem-pages.spec.mjs | 2 +-
shared/matomo-route-tracking.js | 19 ++++++
shared/matomo-tracker.js | 30 +++++++++
website/docusaurus.config.js | 3 +-
13 files changed, 208 insertions(+), 35 deletions(-)
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index b7e4c66689a..6fc9ec6789a 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -511,6 +511,28 @@ jobs:
test -f website/build/img/integrations/icon-prometheus.svg
test -f website/build/img/architecture.svg
+ - name: Assert every published page carries one Matomo tracker
+ run: |
+ node -e '
+ const fs = require("fs"), path = require("path");
+ const walk = (dir, out = []) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true
})) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) walk(full, out);
+ else if (entry.name.endsWith(".html")) out.push(full);
+ }
+ return out;
+ };
+ const pages = walk("website/build");
+ const bad = pages.filter((file) => (fs.readFileSync(file,
"utf8").match(/data-analytics="matomo"/g) || []).length !== 1);
+ if (bad.length) {
+ console.error(`${bad.length} of ${pages.length} pages lack
exactly one Matomo tracker:`);
+ console.error(bad.slice(0, 50).join("\n"));
+ process.exit(1);
+ }
+ console.log(`${pages.length} pages carry exactly one Matomo
tracker`);
+ '
+
- name: Assert every version-picker link resolves
run: |
# The picker used to link version roots (/docs/apisix/3.15/), which
diff --git a/blog/en/docusaurus.config.js b/blog/en/docusaurus.config.js
index c4001e92dd9..1cf869069e1 100644
--- a/blog/en/docusaurus.config.js
+++ b/blog/en/docusaurus.config.js
@@ -1,4 +1,4 @@
-const { ssrTemplate } = require('../../config/ssrTemplate');
+const { ssrTemplate, matomoRouteTracking } =
require('../../config/ssrTemplate');
const description = 'APISIX is a dynamic, high-performance API Gateway with
features like load balancing, canary release, authentication, and
observability. As an AI Gateway, it enables AI proxying, LLM load balancing,
retries, fallbacks, token-based rate limiting, and security to enhance AI agent
efficiency and reliability.';
@@ -131,6 +131,7 @@ module.exports = {
},
],
stylesheets: [],
+ clientModules: [matomoRouteTracking],
/**
* We need to check if we build site for preview env,
* or preview site will load static assets from the asf-site branch.
diff --git a/blog/zh/docusaurus.config.js b/blog/zh/docusaurus.config.js
index 7dcf22c4fb6..3e8d530628b 100644
--- a/blog/zh/docusaurus.config.js
+++ b/blog/zh/docusaurus.config.js
@@ -1,5 +1,5 @@
const readingTime = require('reading-time');
-const { ssrTemplate } = require('../../config/ssrTemplate');
+const { ssrTemplate, matomoRouteTracking } =
require('../../config/ssrTemplate');
const description = 'APISIX is a dynamic, high-performance API Gateway with
features like load balancing, canary release, authentication, and
observability. As an AI Gateway, it enables AI proxying, LLM load balancing,
retries, fallbacks, token-based rate limiting, and security to enhance AI agent
efficiency and reliability.';
@@ -133,6 +133,7 @@ module.exports = {
},
],
stylesheets: [],
+ clientModules: [matomoRouteTracking],
/**
* We need to check if we build site for preview env,
* or preview site will load static assets from the asf-site branch.
diff --git a/config/ssrTemplate.js b/config/ssrTemplate.js
index 8bd22cd7d32..efa5c867ca0 100644
--- a/config/ssrTemplate.js
+++ b/config/ssrTemplate.js
@@ -1,3 +1,8 @@
+const fs = require('fs');
+const path = require('path');
+
+const matomoTracker = fs.readFileSync(path.join(__dirname,
'../shared/matomo-tracker.js'), 'utf8');
+
module.exports = {
ssrTemplate: `<!DOCTYPE html>
<html <%~ it.htmlAttributes %>>
@@ -26,31 +31,7 @@ module.exports = {
<link rel="stylesheet" href="<%= process.env.preview ? '' :
'https://apisix-website-static.apiseven.com' %><%= it.baseUrl %><%= stylesheet
%>" />
<% }); %>
<!-- Matomo from the ASF -->
- <script>
- window._paq = window._paq || [];
- function loadMatomo() {
- var _paq = window._paq;
- /* tracker methods like "setCustomDimension" should be called before
- "trackPageView" */
- /* We explicitly disable cookie tracking to avoid privacy issues */
- _paq.push(['disableCookies']);
- _paq.push(['trackPageView']);
- _paq.push(['enableLinkTracking']);
- (function() {
- var u="https://analytics.apache.org/";
- _paq.push(['setTrackerUrl', u+'matomo.php']);
- _paq.push(['setSiteId', '17']);
- var d=document, g=d.createElement('script'),
- s=d.getElementsByTagName('script')[0];
- g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
- })();
- }
- if ('requestIdleCallback' in window) {
- window.requestIdleCallback(loadMatomo, { timeout: 3000 });
- } else {
- window.addEventListener('load', loadMatomo, { once: true });
- }
- </script>
+ <script data-analytics="matomo">${matomoTracker}</script>
<!-- End Matomo Code -->
</head>
<body <%~ it.bodyAttributes %>>
@@ -74,4 +55,5 @@ module.exports = {
</script>
<% } %>
</html>`,
+ matomoRouteTracking: path.join(__dirname,
'../shared/matomo-route-tracking.js'),
};
diff --git a/doc/docusaurus.config.js b/doc/docusaurus.config.js
index b8a6e519e40..6b7eb2a86f6 100644
--- a/doc/docusaurus.config.js
+++ b/doc/docusaurus.config.js
@@ -1,6 +1,6 @@
const fs = require('fs');
const path = require('path');
-const { ssrTemplate } = require('../config/ssrTemplate');
+const { ssrTemplate, matomoRouteTracking } = require('../config/ssrTemplate');
/**
* Give the newest release an explicit versioned path so it stops owning the
@@ -321,6 +321,7 @@ module.exports = {
},
],
stylesheets: [],
+ clientModules: [matomoRouteTracking],
/**
* We need to check if we build site for preview env,
* or preview site will load static assets from the asf-site branch.
diff --git a/next/MIGRATION.md b/next/MIGRATION.md
index 3aa52c3d180..ebba371d1a7 100644
--- a/next/MIGRATION.md
+++ b/next/MIGRATION.md
@@ -14,7 +14,7 @@ The site is *already* served as static files from the
`asf-site` branch by ASF h
Regenerate the identical URL space with [Astro](https://astro.build) in static
output mode:
-- **Zero client JS.** Pages are plain HTML + one shared 2.7 KB gz stylesheet.
(Single exception: `/edit/`, a redirector that inherently needs 3 lines of
inline JS. Algolia DocSearch and the kapa.ai widget can be re-added as
progressive enhancements if wanted — they are additive `<script>` tags, not
framework requirements.)
+- **Zero framework JS.** Pages are plain HTML + one shared 2.7 KB gz
stylesheet. (Exceptions: the ASF Matomo snippet shared with the Docusaurus
template via `shared/matomo-tracker.js`, and `/edit/`, a redirector that
inherently needs 3 lines of inline JS. Algolia DocSearch and the kapa.ai widget
can be re-added as progressive enhancements if wanted — they are additive
`<script>` tags, not framework requirements.)
- **Same content sources.** Blog/learning-center/articles markdown lives in
the repo as today; project docs are synced from apache/apisix and the six
sub-project repos exactly as `sync-docs.js` does now
(`scripts/sync-content.mjs` replaces it, ~120 lines).
- **Same deploy.** `dist/` → `asf-site` branch → ASF httpd. `.htaccess`
redirects, staging profile, CI trigger cadence: all unchanged.
- **Same URLs.** Verified mechanically, not by hand — see below.
@@ -35,7 +35,7 @@ Regenerate the identical URL space with
[Astro](https://astro.build) in static o
| Pages built | 1368 (654 EN + 640 ZH sitemap URLs + versionless extras) in
**~18 s** on a laptop |
| Homepage transfer | 2.6 KB gz HTML + 2.7 KB gz CSS ≈ **5.3 KB** vs ~200 KB+
today (≈40×) |
| Docs page transfer | 7.3 KB gz HTML + shared CSS |
-| Client JS | 0 bytes on all indexable pages |
+| Client JS | 0 bytes of framework JS on all indexable pages; only the ~0.5 KB
gz inline Matomo snippet |
| SEO surface | per-page canonical, `hreflang` en/zh-CN/x-default, meta
description, OG/Twitter, JSON-LD (WebSite/Organization +
BlogPosting/TechArticle/FAQPage), robots.txt, split EN/ZH sitemaps — same shape
as today |
URL rules that had to be reverse-engineered and are now encoded in
`src/lib/content.ts` (each verified against the live sitemap):
diff --git a/next/README.md b/next/README.md
index ffbfdd687ab..e77eac43f75 100644
--- a/next/README.md
+++ b/next/README.md
@@ -6,8 +6,8 @@ Prototype rebuild of
[apisix.apache.org](https://apisix.apache.org) as a **fully
| | Current (Docusaurus ×4 workspaces) | This prototype (Astro) |
|---|---|---|
-| Homepage first-load transfer | ~350 KB HTML + ~120 KB gz JS + 36 KB gz CSS |
**2.6 KB gz HTML + 2.7 KB gz CSS, 0 JS** |
-| Client-side JS | React SPA per workspace (stale-manifest 404s) | **None**
(one inline snippet on `/edit/` only) |
+| Homepage first-load transfer | ~350 KB HTML + ~120 KB gz JS + 36 KB gz CSS |
**2.6 KB gz HTML + 2.7 KB gz CSS, 0 framework JS** |
+| Client-side JS | React SPA per workspace (stale-manifest 404s) | **No
framework JS** — the shared ASF Matomo snippet (`shared/matomo-tracker.js`,
~0.5 KB gz inline) on every page, plus one inline snippet on `/edit/` |
| Build | Node 16, 4 Docusaurus builds, tens of minutes | Node 22, **1368
pages in ~18 s** |
| URL parity vs live sitemaps | — | **1294/1294, zero missing** |
diff --git a/next/src/layouts/Base.astro b/next/src/layouts/Base.astro
index 2ee05dfc82b..21ad7825758 100644
--- a/next/src/layouts/Base.astro
+++ b/next/src/layouts/Base.astro
@@ -2,6 +2,7 @@
import '../styles/global.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
+import matomoTracker from '../../../shared/matomo-tracker.js?raw';
import {
SITE,
SITE_NAME,
@@ -29,7 +30,7 @@ interface Props {
/** Open Graph object type; article pages pass "article". */
ogType?: 'website' | 'article';
noindex?: boolean;
- /** Load Algolia DocSearch (docs pages only — every other page stays
zero-JS). */
+ /** Load Algolia DocSearch on docs pages. */
search?: boolean;
/** Cross-site canonical embedded in upstream docs (docs.api7.ai hub pages).
*/
canonicalOverride?: string;
@@ -104,6 +105,7 @@ const allJsonLd = [...ORG_JSONLD, ...jsonLd];
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@docsearch/css@3" />
</>
)}
+ <script is:inline data-analytics="matomo" set:html={matomoTracker} />
</head>
<body>
<Header locale={locale} path={path} search={search} switchPath={switchPath}
/>
diff --git a/next/tests/e2e/analytics.spec.mjs
b/next/tests/e2e/analytics.spec.mjs
new file mode 100644
index 00000000000..ecd0e300c35
--- /dev/null
+++ b/next/tests/e2e/analytics.spec.mjs
@@ -0,0 +1,114 @@
+import { expect, test } from '@playwright/test';
+
+const PRODUCTION = 'https://apisix.apache.org';
+const baseURL = (process.env.PLAYWRIGHT_BASE_URL ??
'http://127.0.0.1:4321').replace(/\/$/, '');
+
+const matomoStub = `(() => {
+ const commands = [];
+ window.__matomoCommands = commands;
+ (window._paq || []).forEach((command) => commands.push(command));
+ window._paq = { push: (...pushed) => pushed.forEach((command) =>
commands.push(command)) };
+})();`;
+
+async function serveAsProduction(page) {
+ await page.route('https://analytics.apache.org/**', async (route) => {
+ if (new URL(route.request().url()).pathname === '/matomo.js') {
+ await route.fulfill({ contentType: 'text/javascript', body: matomoStub
});
+ } else {
+ await route.fulfill({ status: 204, body: '' });
+ }
+ });
+ await page.route(`${PRODUCTION}/**`, async (route) => {
+ const { pathname, search } = new URL(route.request().url());
+ const response = await route.fetch({ url: `${baseURL}${pathname}${search}`
});
+ await route.fulfill({ response });
+ });
+}
+
+test.afterEach(async ({ page }) => {
+ await page.unrouteAll({ behavior: 'ignoreErrors' });
+});
+
+const recorded = (page) => page.waitForFunction(() => window.__matomoCommands,
null, { timeout: 10_000 })
+ .then(() => page.evaluate(() => window.__matomoCommands));
+
+const pageViews = (commands) => {
+ const views = [];
+ let pending = {};
+ commands.forEach(([name, value]) => {
+ if (name === 'setCustomUrl') pending.url = value;
+ if (name === 'setDocumentTitle') pending.title = value;
+ if (name === 'setReferrerUrl') pending.referrer = value;
+ if (name === 'trackPageView') {
+ views.push(pending);
+ pending = { ...pending, referrer: undefined };
+ }
+ });
+ return views;
+};
+
+test('Astro pages send one cookieless page view to site 17', async ({ page,
context }) => {
+ await serveAsProduction(page);
+ for (const path of ['/', '/zh/', '/blog/', '/integrations/redis/']) {
+ await page.goto(`${PRODUCTION}${path}`);
+ const commands = await recorded(page);
+ expect(commands).toContainEqual(['disableCookies']);
+ expect(commands).toContainEqual(['setSiteId', '17']);
+ expect(commands).toContainEqual(['setTrackerUrl',
'https://analytics.apache.org/matomo.php']);
+ expect(pageViews(commands)).toEqual([
+ { url: `${PRODUCTION}${path}`, title: await page.title() },
+ ]);
+ }
+ expect(await context.cookies()).toEqual([]);
+});
+
+test('non-production hosts load no tracker', async ({ page }) => {
+ const requests = [];
+ await page.route('https://analytics.apache.org/**', async (route) => {
+ requests.push(route.request().url());
+ await route.abort();
+ });
+ await page.goto('/');
+ await page.waitForTimeout(3_500);
+ expect(await page.evaluate(() => window._paq)).toBeUndefined();
+ expect(requests).toEqual([]);
+});
+
+test('Docusaurus client-side navigation is tracked once per page', async ({
page }) => {
+ test.setTimeout(90_000);
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'desktop-chrome',
+ 'Docusaurus archives only exist in the final overlaid tree',
+ );
+
+ await serveAsProduction(page);
+ const start = `${PRODUCTION}/docs/apisix/next/plugins/cors/`;
+ await page.goto(start);
+ await expect(page.locator('#__docusaurus')).toHaveCount(1);
+ const startTitle = await page.title();
+ expect(pageViews(await recorded(page))).toEqual([{ url: start, title:
startTitle }]);
+
+ const anchor = page.locator('.table-of-contents a[href^="#"]').first();
+ await anchor.click();
+ await expect(page).toHaveURL(/#/);
+
+ const target = page
+ .locator('.menu
a.menu__link[href^="/docs/apisix/next/"]:not([href*="/plugins/cors/"]):visible')
+ .first();
+ const targetPath = await target.getAttribute('href');
+ await target.click();
+ await expect(page).toHaveURL(`${PRODUCTION}${targetPath}`);
+ await expect.poll(async () => pageViews(await
recorded(page)).length).toBe(2);
+
+ const [, view] = pageViews(await recorded(page));
+ expect(view.url).toBe(`${PRODUCTION}${targetPath}`);
+ expect(view.referrer).toBe(start);
+ expect(view.title).toBe(await page.title());
+ expect(view.title).not.toBe(startTitle);
+
+ await page.goBack();
+ await expect(page).toHaveURL(/\/plugins\/cors\//);
+ await expect.poll(async () => pageViews(await
recorded(page)).length).toBe(3);
+ expect(pageViews(await recorded(page))[2].title).toBe(startTitle);
+});
diff --git a/next/tests/e2e/ecosystem-pages.spec.mjs
b/next/tests/e2e/ecosystem-pages.spec.mjs
index 1387a82a389..20f9347a8a4 100644
--- a/next/tests/e2e/ecosystem-pages.spec.mjs
+++ b/next/tests/e2e/ecosystem-pages.spec.mjs
@@ -163,7 +163,7 @@ test('Redis detail pages expose translations,
relationships, and source-review b
.toHaveAttribute('href', 'https://apisix.apache.org/integrations/redis/');
await expect(page.locator('link[rel="alternate"][hreflang="zh"]'))
.toHaveAttribute('href',
'https://apisix.apache.org/zh/integrations/redis/');
- await
expect(page.locator('script:not([type="application/ld+json"])')).toHaveCount(0);
+ await
expect(page.locator('script:not([type="application/ld+json"]):not([data-analytics="matomo"])')).toHaveCount(0);
const schema = await jsonLd(page);
expect(schema.some((item) => item['@type'] === 'TechArticle')).toBeTruthy();
expect(schema.some((item) => item['@type'] ===
'BreadcrumbList')).toBeTruthy();
diff --git a/shared/matomo-route-tracking.js b/shared/matomo-route-tracking.js
new file mode 100644
index 00000000000..8aefbe3ad39
--- /dev/null
+++ b/shared/matomo-route-tracking.js
@@ -0,0 +1,19 @@
+/* eslint-disable no-underscore-dangle */
+export default {
+ onRouteUpdate({ location, previousLocation }) {
+ if (typeof window === 'undefined' || !window._paq || !previousLocation)
return;
+ if (location.pathname === previousLocation.pathname
+ && location.search === previousLocation.search) return;
+
+ const url = window.location.href;
+ const referrer =
`${window.location.origin}${previousLocation.pathname}${previousLocation.search}`;
+ window.requestAnimationFrame(() => setTimeout(() => {
+ const queue = window._paq;
+ queue.push(['setReferrerUrl', referrer]);
+ queue.push(['setCustomUrl', url]);
+ queue.push(['setDocumentTitle', document.title]);
+ queue.push(['trackPageView']);
+ queue.push(['enableLinkTracking']);
+ }, 0));
+ },
+};
diff --git a/shared/matomo-tracker.js b/shared/matomo-tracker.js
new file mode 100644
index 00000000000..01e13c566c3
--- /dev/null
+++ b/shared/matomo-tracker.js
@@ -0,0 +1,30 @@
+/* eslint-disable no-underscore-dangle */
+(() => {
+ if (window.location.hostname !== 'apisix.apache.org') return;
+ const trackerUrl = 'https://analytics.apache.org/';
+ window._paq = window._paq || [];
+ const queue = window._paq;
+ /* tracker methods like "setCustomDimension" should be called before
+ "trackPageView" */
+ /* We explicitly disable cookie tracking to avoid privacy issues */
+ queue.push(['disableCookies']);
+ queue.push(['setTrackerUrl', `${trackerUrl}matomo.php`]);
+ queue.push(['setSiteId', '17']);
+ queue.push(['setCustomUrl', window.location.href]);
+ queue.push(['setDocumentTitle', document.title]);
+ queue.push(['trackPageView']);
+ queue.push(['enableLinkTracking']);
+
+ const loadMatomo = () => {
+ const script = document.createElement('script');
+ script.async = true;
+ script.src = `${trackerUrl}matomo.js`;
+ document.head.appendChild(script);
+ };
+
+ if ('requestIdleCallback' in window) {
+ window.requestIdleCallback(loadMatomo, { timeout: 3000 });
+ } else {
+ window.addEventListener('load', loadMatomo, { once: true });
+ }
+})();
diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js
index 2e8a2136ad6..c3777cead89 100644
--- a/website/docusaurus.config.js
+++ b/website/docusaurus.config.js
@@ -1,4 +1,4 @@
-const { ssrTemplate } = require('../config/ssrTemplate');
+const { ssrTemplate, matomoRouteTracking } = require('../config/ssrTemplate');
module.exports = {
title: 'Apache APISIX',
@@ -188,6 +188,7 @@ module.exports = {
},
],
stylesheets: [],
+ clientModules: [matomoRouteTracking],
/**
* We need to check if we build site for preview env,
* or preview site will load static assets from the asf-site branch.