Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a6bb22d
Bind Meilisearch searches to monthly pseudonymous user id with DNT
web-flow Dec 4, 2025
63a5879
Document docs search analytics and monthly pseudonymous id with DNT
web-flow Dec 4, 2025
5e66feb
Refine docs search analytics disclosure and add note callout
web-flow Dec 4, 2025
0c572ba
Revise privacy note wording for docs search analytics
web-flow Dec 4, 2025
de28926
Apply suggestion from @pwizla
pwizla Dec 4, 2025
6eaab99
Rework usage information page
pwizla Dec 4, 2025
aefc42b
Fix duplicate labels in sidebar
pwizla Dec 4, 2025
59a3893
Attach X-MS-USER-ID for XHR-based Meilisearch requests
web-flow Dec 4, 2025
fbe4784
Add X-MS-USER-ID via docsearch requestConfig and headers
web-flow Dec 5, 2025
f738a47
Add X-MS-USER-ID via docsearch options and guard init timing
web-flow Dec 5, 2025
c279813
Merge branch 'repo/fix-meilisearch-analytics' of github.com:strapi/do…
web-flow Dec 5, 2025
6408d50
Remove `key` declarations in sidebar
pwizla Dec 5, 2025
c18bbce
Route docsearch to Vercel proxy and forward X-MS-USER-ID server-side
web-flow Dec 5, 2025
0a0080b
Simplify: remove proxy; keep monthly msUserId only
web-flow Dec 5, 2025
04a51ce
Revert "Simplify: remove proxy; keep monthly msUserId only"
web-flow Dec 5, 2025
e595f14
Revert "Route docsearch to Vercel proxy and forward X-MS-USER-ID serv…
web-flow Dec 5, 2025
3047ea2
Route search to same-origin API and pass X-MS-USER-ID header
web-flow Dec 5, 2025
9088dd3
docs(search): route docsearch via same-origin API and attach X-MS-USE…
web-flow Dec 5, 2025
4a96f63
docs(search): keep only root API route and fix path to /api/indexes/s…
web-flow Dec 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions api/indexes/strapi-docs/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export default async function handler(req, res) {
if (req.method !== 'POST') {
res.status(405).json({ error: 'Method Not Allowed' });
return;
}

try {
const { indexUid } = req.query || {};
// Enforce index name from path folder
if (indexUid !== 'strapi-docs') {
res.status(400).json({ error: 'Invalid indexUid' });
return;
}

// Read Meilisearch project URL and key from headers or env
const hostHeader = req.headers['x-meili-host'];
const keyHeader = req.headers['x-meili-api-key'];
const host = (typeof hostHeader === 'string' && hostHeader) || process.env.MEILI_HOST || process.env.NEXT_PUBLIC_MEILI_HOST;
const apiKey = (typeof keyHeader === 'string' && keyHeader) || process.env.MEILI_API_KEY || process.env.NEXT_PUBLIC_MEILI_API_KEY;

if (!host || !apiKey) {
res.status(500).json({ error: 'Meilisearch host or API key not configured' });
return;
}

// Forward X-MS-USER-ID if present from the browser request (same-origin; no CORS issue)
const userId = req.headers['x-ms-user-id'];

const url = new URL(`/indexes/strapi-docs/search`, host);
const upstream = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'X-Meilisearch-Client': req.headers['x-meilisearch-client'] || 'StrapiDocs Proxy',
...(userId ? { 'X-MS-USER-ID': String(userId) } : {}),
},
body: JSON.stringify(req.body || {}),
});

const body = await upstream.text();
res.status(upstream.status);
try {
res.setHeader('Content-Type', 'application/json');
res.send(body);
} catch {
res.json({ error: 'Invalid upstream response' });
}
} catch (e) {
res.status(500).json({ error: e.message || 'Proxy error' });
}
}

21 changes: 20 additions & 1 deletion docusaurus/docs/cms/usage-information.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ However, these above actions alone are often insufficient to maintain an overall

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.

## Collected data
## Collected Strapi-related data

The following data is collected:

Expand Down Expand Up @@ -82,3 +82,22 @@ Data collection can later be re-enabled by deleting the flag or setting it to fa
:::note
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).
:::

## Collected search-related data for docs.strapi.io

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:

- Total searches
- Total users (estimated)
- Most searched keywords
- Searches without results

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.

We do not send click-through or conversion events to Meilisearch.

### Opt-out

If your browser’s Do Not Track setting is enabled, the site does not create or send this identifier.

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.
4 changes: 4 additions & 0 deletions docusaurus/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ const sidebars = {
type: 'doc',
id: 'cms/customization', // TODO: rename to Introduction
label: 'Introduction',
// key: 'cms-customization-introduction',
},
'cms/configurations/functions',
{
Expand All @@ -376,6 +377,7 @@ const sidebars = {
type: 'doc',
id: 'cms/backend-customization',
label: 'Overview',
// key: 'cms-backend-customization-overview',
},
'cms/backend-customization/requests-responses',
'cms/backend-customization/routes',
Expand Down Expand Up @@ -415,6 +417,7 @@ const sidebars = {
type: 'doc',
id: 'cms/admin-panel-customization',
label: 'Overview',
// key: 'cms-admin-panel-customization-overview',
},
'cms/admin-panel-customization/logos',
'cms/admin-panel-customization/favicon',
Expand Down Expand Up @@ -457,6 +460,7 @@ const sidebars = {
type: 'doc',
id: 'cms/typescript',
label: 'Introduction',
// key: 'cms-typescript-introduction',
},
{
type: 'doc',
Expand Down
196 changes: 169 additions & 27 deletions docusaurus/src/theme/SearchBar/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,56 @@ function SearchBarContent() {
const dropdownRef = useRef(null);
const searchInstanceRef = useRef(null);
const [isLoaded, setIsLoaded] = useState(false);
const originalFetchRef = useRef(null);

useEffect(() => {
if (!searchButtonRef.current) {
return;
function isDoNotTrackEnabled() {
try {
const dnt = (navigator.doNotTrack || window.doNotTrack || navigator.msDoNotTrack || '').toString();
return dnt === '1' || dnt.toLowerCase() === 'yes';
} catch (_) {
return false;
}
}

function getOrCreateMonthlyUserId() {
if (isDoNotTrackEnabled()) return null;
try {
const key = 'msUserId';
const now = new Date();
const monthKey = now.toISOString().slice(0, 7); // YYYY-MM (UTC)
const raw = window.localStorage.getItem(key);
if (raw) {
try {
const parsed = JSON.parse(raw);
if (parsed && parsed.id && parsed.month === monthKey) {
return parsed.id;
}
} catch {}
}
const uuid = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36);
const value = JSON.stringify({ id: uuid, month: monthKey });
window.localStorage.setItem(key, value);
return uuid;
} catch (_) {
return null;
}
}

function shouldAttachUserIdHeader(urlStr) {
try {
const meiliHost = siteConfig?.customFields?.meilisearch?.host;
if (!meiliHost) return false;
const u = new URL(urlStr, window.location.origin);
const meili = new URL(meiliHost);
if (u.origin !== meili.origin) return false;
// Only for search requests
return /\/indexes\/[^/]+\/search$/.test(u.pathname);
} catch {
return false;
}
}

useEffect(() => {

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

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

// Prepare pseudonymous monthly user id (respects DNT)
const userId = getOrCreateMonthlyUserId();

// Scoped fetch interceptor to add X-MS-USER-ID for Meilisearch search requests
if (typeof window !== 'undefined' && window.fetch && !originalFetchRef.current) {
originalFetchRef.current = window.fetch.bind(window);
window.fetch = async (input, init) => {
try {
const url = typeof input === 'string' ? input : (input && input.url) ? input.url : '';
if (!userId || !shouldAttachUserIdHeader(url)) {
return originalFetchRef.current(input, init);
}

// Attach header depending on input type
if (typeof input === 'string' || input instanceof URL) {
const headers = new Headers(init && init.headers ? init.headers : undefined);
if (!headers.has('X-MS-USER-ID')) headers.set('X-MS-USER-ID', userId);
return originalFetchRef.current(input, { ...(init || {}), headers });
}

// input is Request
const req = input;
const headers = new Headers(req.headers);
if (!headers.has('X-MS-USER-ID')) headers.set('X-MS-USER-ID', userId);
const newReq = new Request(req, { headers });
return originalFetchRef.current(newReq);
} catch (_) {
return originalFetchRef.current(input, init);
}
};
}

// Also patch XMLHttpRequest for libraries that use XHR under the hood
const originalXHROpen = (typeof XMLHttpRequest !== 'undefined' && XMLHttpRequest.prototype.open) ? XMLHttpRequest.prototype.open : null;
const originalXHRSend = (typeof XMLHttpRequest !== 'undefined' && XMLHttpRequest.prototype.send) ? XMLHttpRequest.prototype.send : null;
let xhrPatched = false;
if (originalXHROpen && originalXHRSend) {
try {
XMLHttpRequest.prototype.open = function(method, url, async, user, password) {
try { this.__ms_url = url; } catch {}
return originalXHROpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
try {
if (userId && this && typeof this.setRequestHeader === 'function') {
const url = this.__ms_url || '';
if (shouldAttachUserIdHeader(url)) {
// Only set if not already set
try { this.setRequestHeader('X-MS-USER-ID', userId); } catch {}
}
}
} catch {}
return originalXHRSend.apply(this, arguments);
};
xhrPatched = true;
} catch {}
}

if (searchInstanceRef.current) {
searchInstanceRef.current.destroy?.();
searchInstanceRef.current = null;
}

searchButtonRef.current.innerHTML = '';

Promise.all([
import('meilisearch-docsearch'),
import('meilisearch-docsearch/css')
]).then(([{ docsearch }]) => {
const search = docsearch({
container: searchButtonRef.current,
host: siteConfig.customFields.meilisearch.host,
apiKey: siteConfig.customFields.meilisearch.apiKey,
indexUid: siteConfig.customFields.meilisearch.indexUid,

if (searchButtonRef.current) {
searchButtonRef.current.innerHTML = '';
}

// Initialize docsearch only when container is ready
if (searchButtonRef.current) {
Promise.all([
import('meilisearch-docsearch'),
import('meilisearch-docsearch/css')
]).then(([{ docsearch }]) => {
const baseOptions = {
container: searchButtonRef.current,
// Route through same-origin API to add headers server-side without CORS
host: `${window.location.origin}/api`,
// dummy key for docsearch client; real key sent via header to API
apiKey: 'public',
indexUid: siteConfig.customFields.meilisearch.indexUid,

transformItems: (items) => {
return items.map((item) => {
Expand Down Expand Up @@ -135,22 +244,55 @@ function SearchBarContent() {
getMissingResultsUrl: ({ query }) => {
return `https://github.com/strapi/documentation/issues/new?title=Missing+search+results+for+${query}`;
},
});
};

searchInstanceRef.current = search;
setIsLoaded(true);
// Send X-MS-USER-ID to same-origin API; no CORS preflight restrictions
const meiliHost = siteConfig.customFields.meilisearch.host;
const meiliKey = siteConfig.customFields.meilisearch.apiKey;
baseOptions.requestConfig = {
...(baseOptions.requestConfig || {}),
headers: {
...(userId ? { 'X-MS-USER-ID': userId } : {}),
...(meiliHost ? { 'X-Meili-Host': meiliHost } : {}),
...(meiliKey ? { 'X-Meili-Api-Key': meiliKey } : {}),
},
};
baseOptions.headers = {
...(baseOptions.headers || {}),
...(userId ? { 'X-MS-USER-ID': userId } : {}),
...(meiliHost ? { 'X-Meili-Host': meiliHost } : {}),
...(meiliKey ? { 'X-Meili-Api-Key': meiliKey } : {}),
};

if (colorMode === 'dark') {
dropdownRef.current?.classList.add('dark');
} else {
dropdownRef.current?.classList.remove('dark');
}
}).catch((error) => {
console.error('Failed to load MeiliSearch:', error);
});
const search = docsearch(baseOptions);

searchInstanceRef.current = search;
setIsLoaded(true);

if (colorMode === 'dark') {
dropdownRef.current?.classList.add('dark');
} else {
dropdownRef.current?.classList.remove('dark');
}
}).catch((error) => {
console.error('Failed to load MeiliSearch:', error);
});
}

return () => {
document.removeEventListener('keydown', handleKeyDown, true);
if (originalFetchRef.current) {
try {
window.fetch = originalFetchRef.current;
} catch {}
originalFetchRef.current = null;
}
if (xhrPatched && originalXHROpen && originalXHRSend) {
try {
XMLHttpRequest.prototype.open = originalXHROpen;
XMLHttpRequest.prototype.send = originalXHRSend;
} catch {}
}
if (searchInstanceRef.current) {
searchInstanceRef.current.destroy?.();
searchInstanceRef.current = null;
Expand All @@ -171,4 +313,4 @@ export default function SearchBar() {
{() => <SearchBarContent />}
</BrowserOnly>
);
}
}
Loading