Skip to content

Commit 7ada99f

Browse files
pwizlaweb-flow
andauthored
Fix MeiliSearch analytics tracking (#2888)
* Bind Meilisearch searches to monthly pseudonymous user id with DNT * Document docs search analytics and monthly pseudonymous id with DNT * Refine docs search analytics disclosure and add note callout * Revise privacy note wording for docs search analytics * Apply suggestion from @pwizla * Rework usage information page * Fix duplicate labels in sidebar * Attach X-MS-USER-ID for XHR-based Meilisearch requests * Add X-MS-USER-ID via docsearch requestConfig and headers * Add X-MS-USER-ID via docsearch options and guard init timing * Remove `key` declarations in sidebar Only useful for later Docusaurus upgrades * Route docsearch to Vercel proxy and forward X-MS-USER-ID server-side * Simplify: remove proxy; keep monthly msUserId only * Revert "Simplify: remove proxy; keep monthly msUserId only" This reverts commit 0a0080b. * Revert "Route docsearch to Vercel proxy and forward X-MS-USER-ID server-side" This reverts commit c18bbce. * Route search to same-origin API and pass X-MS-USER-ID header * docs(search): route docsearch via same-origin API and attach X-MS-USER-ID; add root API proxy; forward Meilisearch host/key via headers\n\n- Add api/indexes/[indexUid]/search.js to proxy POST /api/indexes/:indexUid/search to Meilisearch and set X-MS-USER-ID server-side\n- Keep host/apiKey in docusaurus.config.js but pass via headers (X-Meili-Host, X-Meili-Api-Key) to avoid literals in server code\n- Update SearchBar to use same-origin /api host; respect DNT and monthly rotation for msUserId\n- Restore search UI while enabling user-bound analytics without CORS issues * docs(search): keep only root API route and fix path to /api/indexes/strapi-docs/search\n\n- Remove duplicate function under docusaurus/api\n- Replace dynamic [indexUid] route with fixed strapi-docs path to match our single index\n- Keep header-forwarded host/key and X-MS-USER-ID support --------- Co-authored-by: GitHub Actions <noreply@github.com>
1 parent 361f271 commit 7ada99f

File tree

4 files changed

+246
-28
lines changed

4 files changed

+246
-28
lines changed

api/indexes/strapi-docs/search.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export default async function handler(req, res) {
2+
if (req.method !== 'POST') {
3+
res.status(405).json({ error: 'Method Not Allowed' });
4+
return;
5+
}
6+
7+
try {
8+
const { indexUid } = req.query || {};
9+
// Enforce index name from path folder
10+
if (indexUid !== 'strapi-docs') {
11+
res.status(400).json({ error: 'Invalid indexUid' });
12+
return;
13+
}
14+
15+
// Read Meilisearch project URL and key from headers or env
16+
const hostHeader = req.headers['x-meili-host'];
17+
const keyHeader = req.headers['x-meili-api-key'];
18+
const host = (typeof hostHeader === 'string' && hostHeader) || process.env.MEILI_HOST || process.env.NEXT_PUBLIC_MEILI_HOST;
19+
const apiKey = (typeof keyHeader === 'string' && keyHeader) || process.env.MEILI_API_KEY || process.env.NEXT_PUBLIC_MEILI_API_KEY;
20+
21+
if (!host || !apiKey) {
22+
res.status(500).json({ error: 'Meilisearch host or API key not configured' });
23+
return;
24+
}
25+
26+
// Forward X-MS-USER-ID if present from the browser request (same-origin; no CORS issue)
27+
const userId = req.headers['x-ms-user-id'];
28+
29+
const url = new URL(`/indexes/strapi-docs/search`, host);
30+
const upstream = await fetch(url.toString(), {
31+
method: 'POST',
32+
headers: {
33+
'Content-Type': 'application/json',
34+
'Authorization': `Bearer ${apiKey}`,
35+
'X-Meilisearch-Client': req.headers['x-meilisearch-client'] || 'StrapiDocs Proxy',
36+
...(userId ? { 'X-MS-USER-ID': String(userId) } : {}),
37+
},
38+
body: JSON.stringify(req.body || {}),
39+
});
40+
41+
const body = await upstream.text();
42+
res.status(upstream.status);
43+
try {
44+
res.setHeader('Content-Type', 'application/json');
45+
res.send(body);
46+
} catch {
47+
res.json({ error: 'Invalid upstream response' });
48+
}
49+
} catch (e) {
50+
res.status(500).json({ error: e.message || 'Proxy error' });
51+
}
52+
}
53+

docusaurus/docs/cms/usage-information.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ However, these above actions alone are often insufficient to maintain an overall
2727

2828
Without these metrics, we wouldn't be able to make the right choices as we continue to move forward with the roadmap and provide what you, the community and users, are asking for.
2929

30-
## Collected data
30+
## Collected Strapi-related data
3131

3232
The following data is collected:
3333

@@ -82,3 +82,22 @@ Data collection can later be re-enabled by deleting the flag or setting it to fa
8282
:::note
8383
If you have any questions or concerns regarding data collection, please contact us at the following email address [privacy@strapi.io](mailto:privacy@strapi.io).
8484
:::
85+
86+
## Collected search-related data for docs.strapi.io
87+
88+
To improve our documentation, the public website at `docs.strapi.io` collects anonymous search usage metrics using Meilisearch Cloud. These metrics help us understand how the search performs and where we can make it better:
89+
90+
- Total searches
91+
- Total users (estimated)
92+
- Most searched keywords
93+
- Searches without results
94+
95+
To make the “Total users” metric more accurate while preserving privacy, the site creates a pseudonymous identifier in the browser’s localStorage under the `msUserId` key. It is randomly generated, contains no personal data, rotates on the first visit of each calendar month (UTC), and is sent only with documentation search requests as the `X-MS-USER-ID` header. It is not used for any other purpose.
96+
97+
We do not send click-through or conversion events to Meilisearch.
98+
99+
### Opt-out
100+
101+
If your browser’s Do Not Track setting is enabled, the site does not create or send this identifier.
102+
103+
If this identifier is created, you can remove it at any time by clearing the `msUserId` entry from your browser’s localStorage for docs.strapi.io.

docusaurus/sidebars.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ const sidebars = {
363363
type: 'doc',
364364
id: 'cms/customization', // TODO: rename to Introduction
365365
label: 'Introduction',
366+
// key: 'cms-customization-introduction',
366367
},
367368
'cms/configurations/functions',
368369
{
@@ -376,6 +377,7 @@ const sidebars = {
376377
type: 'doc',
377378
id: 'cms/backend-customization',
378379
label: 'Overview',
380+
// key: 'cms-backend-customization-overview',
379381
},
380382
'cms/backend-customization/requests-responses',
381383
'cms/backend-customization/routes',
@@ -415,6 +417,7 @@ const sidebars = {
415417
type: 'doc',
416418
id: 'cms/admin-panel-customization',
417419
label: 'Overview',
420+
// key: 'cms-admin-panel-customization-overview',
418421
},
419422
'cms/admin-panel-customization/logos',
420423
'cms/admin-panel-customization/favicon',
@@ -457,6 +460,7 @@ const sidebars = {
457460
type: 'doc',
458461
id: 'cms/typescript',
459462
label: 'Introduction',
463+
// key: 'cms-typescript-introduction',
460464
},
461465
{
462466
type: 'doc',

docusaurus/src/theme/SearchBar/index.js

Lines changed: 169 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,56 @@ function SearchBarContent() {
1010
const dropdownRef = useRef(null);
1111
const searchInstanceRef = useRef(null);
1212
const [isLoaded, setIsLoaded] = useState(false);
13+
const originalFetchRef = useRef(null);
1314

14-
useEffect(() => {
15-
if (!searchButtonRef.current) {
16-
return;
15+
function isDoNotTrackEnabled() {
16+
try {
17+
const dnt = (navigator.doNotTrack || window.doNotTrack || navigator.msDoNotTrack || '').toString();
18+
return dnt === '1' || dnt.toLowerCase() === 'yes';
19+
} catch (_) {
20+
return false;
21+
}
22+
}
23+
24+
function getOrCreateMonthlyUserId() {
25+
if (isDoNotTrackEnabled()) return null;
26+
try {
27+
const key = 'msUserId';
28+
const now = new Date();
29+
const monthKey = now.toISOString().slice(0, 7); // YYYY-MM (UTC)
30+
const raw = window.localStorage.getItem(key);
31+
if (raw) {
32+
try {
33+
const parsed = JSON.parse(raw);
34+
if (parsed && parsed.id && parsed.month === monthKey) {
35+
return parsed.id;
36+
}
37+
} catch {}
38+
}
39+
const uuid = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36);
40+
const value = JSON.stringify({ id: uuid, month: monthKey });
41+
window.localStorage.setItem(key, value);
42+
return uuid;
43+
} catch (_) {
44+
return null;
1745
}
46+
}
47+
48+
function shouldAttachUserIdHeader(urlStr) {
49+
try {
50+
const meiliHost = siteConfig?.customFields?.meilisearch?.host;
51+
if (!meiliHost) return false;
52+
const u = new URL(urlStr, window.location.origin);
53+
const meili = new URL(meiliHost);
54+
if (u.origin !== meili.origin) return false;
55+
// Only for search requests
56+
return /\/indexes\/[^/]+\/search$/.test(u.pathname);
57+
} catch {
58+
return false;
59+
}
60+
}
61+
62+
useEffect(() => {
1863

1964
const handleKeyDown = (e) => {
2065
const kapaContainer = document.getElementById('kapa-widget-container');
@@ -40,22 +85,86 @@ function SearchBarContent() {
4085

4186
document.addEventListener('keydown', handleKeyDown, true);
4287

88+
// Prepare pseudonymous monthly user id (respects DNT)
89+
const userId = getOrCreateMonthlyUserId();
90+
91+
// Scoped fetch interceptor to add X-MS-USER-ID for Meilisearch search requests
92+
if (typeof window !== 'undefined' && window.fetch && !originalFetchRef.current) {
93+
originalFetchRef.current = window.fetch.bind(window);
94+
window.fetch = async (input, init) => {
95+
try {
96+
const url = typeof input === 'string' ? input : (input && input.url) ? input.url : '';
97+
if (!userId || !shouldAttachUserIdHeader(url)) {
98+
return originalFetchRef.current(input, init);
99+
}
100+
101+
// Attach header depending on input type
102+
if (typeof input === 'string' || input instanceof URL) {
103+
const headers = new Headers(init && init.headers ? init.headers : undefined);
104+
if (!headers.has('X-MS-USER-ID')) headers.set('X-MS-USER-ID', userId);
105+
return originalFetchRef.current(input, { ...(init || {}), headers });
106+
}
107+
108+
// input is Request
109+
const req = input;
110+
const headers = new Headers(req.headers);
111+
if (!headers.has('X-MS-USER-ID')) headers.set('X-MS-USER-ID', userId);
112+
const newReq = new Request(req, { headers });
113+
return originalFetchRef.current(newReq);
114+
} catch (_) {
115+
return originalFetchRef.current(input, init);
116+
}
117+
};
118+
}
119+
120+
// Also patch XMLHttpRequest for libraries that use XHR under the hood
121+
const originalXHROpen = (typeof XMLHttpRequest !== 'undefined' && XMLHttpRequest.prototype.open) ? XMLHttpRequest.prototype.open : null;
122+
const originalXHRSend = (typeof XMLHttpRequest !== 'undefined' && XMLHttpRequest.prototype.send) ? XMLHttpRequest.prototype.send : null;
123+
let xhrPatched = false;
124+
if (originalXHROpen && originalXHRSend) {
125+
try {
126+
XMLHttpRequest.prototype.open = function(method, url, async, user, password) {
127+
try { this.__ms_url = url; } catch {}
128+
return originalXHROpen.apply(this, arguments);
129+
};
130+
XMLHttpRequest.prototype.send = function(body) {
131+
try {
132+
if (userId && this && typeof this.setRequestHeader === 'function') {
133+
const url = this.__ms_url || '';
134+
if (shouldAttachUserIdHeader(url)) {
135+
// Only set if not already set
136+
try { this.setRequestHeader('X-MS-USER-ID', userId); } catch {}
137+
}
138+
}
139+
} catch {}
140+
return originalXHRSend.apply(this, arguments);
141+
};
142+
xhrPatched = true;
143+
} catch {}
144+
}
145+
43146
if (searchInstanceRef.current) {
44147
searchInstanceRef.current.destroy?.();
45148
searchInstanceRef.current = null;
46149
}
47-
48-
searchButtonRef.current.innerHTML = '';
49-
50-
Promise.all([
51-
import('meilisearch-docsearch'),
52-
import('meilisearch-docsearch/css')
53-
]).then(([{ docsearch }]) => {
54-
const search = docsearch({
55-
container: searchButtonRef.current,
56-
host: siteConfig.customFields.meilisearch.host,
57-
apiKey: siteConfig.customFields.meilisearch.apiKey,
58-
indexUid: siteConfig.customFields.meilisearch.indexUid,
150+
151+
if (searchButtonRef.current) {
152+
searchButtonRef.current.innerHTML = '';
153+
}
154+
155+
// Initialize docsearch only when container is ready
156+
if (searchButtonRef.current) {
157+
Promise.all([
158+
import('meilisearch-docsearch'),
159+
import('meilisearch-docsearch/css')
160+
]).then(([{ docsearch }]) => {
161+
const baseOptions = {
162+
container: searchButtonRef.current,
163+
// Route through same-origin API to add headers server-side without CORS
164+
host: `${window.location.origin}/api`,
165+
// dummy key for docsearch client; real key sent via header to API
166+
apiKey: 'public',
167+
indexUid: siteConfig.customFields.meilisearch.indexUid,
59168

60169
transformItems: (items) => {
61170
return items.map((item) => {
@@ -135,22 +244,55 @@ function SearchBarContent() {
135244
getMissingResultsUrl: ({ query }) => {
136245
return `https://github.com/strapi/documentation/issues/new?title=Missing+search+results+for+${query}`;
137246
},
138-
});
247+
};
139248

140-
searchInstanceRef.current = search;
141-
setIsLoaded(true);
249+
// Send X-MS-USER-ID to same-origin API; no CORS preflight restrictions
250+
const meiliHost = siteConfig.customFields.meilisearch.host;
251+
const meiliKey = siteConfig.customFields.meilisearch.apiKey;
252+
baseOptions.requestConfig = {
253+
...(baseOptions.requestConfig || {}),
254+
headers: {
255+
...(userId ? { 'X-MS-USER-ID': userId } : {}),
256+
...(meiliHost ? { 'X-Meili-Host': meiliHost } : {}),
257+
...(meiliKey ? { 'X-Meili-Api-Key': meiliKey } : {}),
258+
},
259+
};
260+
baseOptions.headers = {
261+
...(baseOptions.headers || {}),
262+
...(userId ? { 'X-MS-USER-ID': userId } : {}),
263+
...(meiliHost ? { 'X-Meili-Host': meiliHost } : {}),
264+
...(meiliKey ? { 'X-Meili-Api-Key': meiliKey } : {}),
265+
};
142266

143-
if (colorMode === 'dark') {
144-
dropdownRef.current?.classList.add('dark');
145-
} else {
146-
dropdownRef.current?.classList.remove('dark');
147-
}
148-
}).catch((error) => {
149-
console.error('Failed to load MeiliSearch:', error);
150-
});
267+
const search = docsearch(baseOptions);
268+
269+
searchInstanceRef.current = search;
270+
setIsLoaded(true);
271+
272+
if (colorMode === 'dark') {
273+
dropdownRef.current?.classList.add('dark');
274+
} else {
275+
dropdownRef.current?.classList.remove('dark');
276+
}
277+
}).catch((error) => {
278+
console.error('Failed to load MeiliSearch:', error);
279+
});
280+
}
151281

152282
return () => {
153283
document.removeEventListener('keydown', handleKeyDown, true);
284+
if (originalFetchRef.current) {
285+
try {
286+
window.fetch = originalFetchRef.current;
287+
} catch {}
288+
originalFetchRef.current = null;
289+
}
290+
if (xhrPatched && originalXHROpen && originalXHRSend) {
291+
try {
292+
XMLHttpRequest.prototype.open = originalXHROpen;
293+
XMLHttpRequest.prototype.send = originalXHRSend;
294+
} catch {}
295+
}
154296
if (searchInstanceRef.current) {
155297
searchInstanceRef.current.destroy?.();
156298
searchInstanceRef.current = null;
@@ -171,4 +313,4 @@ export default function SearchBar() {
171313
{() => <SearchBarContent />}
172314
</BrowserOnly>
173315
);
174-
}
316+
}

0 commit comments

Comments
 (0)