From 5f659b3d52abe60896f33a7c210fe716d9d535ec Mon Sep 17 00:00:00 2001 From: Hao Phan Date: Wed, 25 Mar 2026 16:08:04 +0200 Subject: [PATCH 1/2] [sc-13778] introduce new setting "analyticsKeywordInterceptor" --- README.md | 13 +++++++ src/index.js | 11 +++++- src/util/analytics.js | 66 +++++++++++++++++++++++++++++----- test/util/analytics.js | 81 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3505c49..d2e95fd 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ The configuration object can contain following values: |-------------------------|-----------------------------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| | debug | boolean | false | Log events to console and enable [Redux DevTools](https://github.com/reduxjs/redux-devtools) | | analyticsCallback | function | n/a | A function to call when an analytics event occurs. [Read more](#analytics) | +| analyticsKeywordInterceptor | function | n/a | A function to intercept and modify `keyword` before analytics events are sent. [Read more](#analytics) | | baseFilters | object | null | A filter object that is applied to all searches under the hood. The user can't disable baseFilters | | collectAnalytics | boolean | true | Control if analytics events are collected at all | | matchAllQuery | boolean | false | Execute "match all" query when the Search UI is started | @@ -1115,6 +1116,18 @@ To save clicks reliably before the user's browser leaves the page, it's recommen [navigator.sendBeacon](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon) method. +You can intercept and modify analytics keywords before events are sent by passing +_analyticsKeywordInterceptor_ in the Search UI configuration. + +```js +var searchui = new AddSearchUI(client, { + analyticsKeywordInterceptor: function (event) { + // Replace phone-like patterns with a placeholder before sending analytics + return event.keyword.replace(/\+?\d[\d\s().-]{6,}\d/g, '[REDACTED]'); + } +}); +``` + ## Supported web browsers This library is tested on diff --git a/src/index.js b/src/index.js index 5750d2d..d62c373 100644 --- a/src/index.js +++ b/src/index.js @@ -16,7 +16,11 @@ import Aianswersresult from './components/aianswersresult'; import SegmentedResults from './components/segmentedresults'; import SortBy from './components/sortby'; import { initRedux } from './store'; -import { setExternalAnalyticsCallback, setCollectAnalytics } from './util/analytics'; +import { + setExternalAnalyticsCallback, + setCollectAnalytics, + setAnalyticsKeywordInterceptor +} from './util/analytics'; import { registerDefaultHelpers, registerHelper, registerPartial } from './util/handlebars'; import { initFromUrlOrBrowserStorage, setHistory, HISTORY_PARAMETERS } from './util/history'; import { autocompleteHide } from './actions/autocomplete'; @@ -61,6 +65,7 @@ export default class AddSearchUI { this.client.setCollectAnalytics(false); setExternalAnalyticsCallback(this.settings.analyticsCallback); setCollectAnalytics(this.settings.collectAnalytics); + setAnalyticsKeywordInterceptor(this.settings.analyticsKeywordInterceptor); this.reduxStore.dispatch(setSearchResultsPageUrl(this.settings.searchResultsPageUrl)); @@ -380,6 +385,10 @@ export default class AddSearchUI { setCollectAnalytics(collect); } + setAnalyticsKeywordInterceptor(interceptor) { + setAnalyticsKeywordInterceptor(interceptor); + } + pauseSegmentedSearch(pause) { this.reduxStore.dispatch(setPauseSegmentedSearch(pause)); } diff --git a/src/util/analytics.js b/src/util/analytics.js index dcd3db6..af445ea 100644 --- a/src/util/analytics.js +++ b/src/util/analytics.js @@ -23,6 +23,31 @@ export function setCollectAnalytics(collect) { collectAnalytics = collect; } +/** + * Possibility to intercept analytics keyword before sending analytics events + */ +let analyticsKeywordInterceptor = null; +export function setAnalyticsKeywordInterceptor(cb) { + analyticsKeywordInterceptor = typeof cb === 'function' ? cb : null; +} + +function getAnalyticsKeyword(action, keyword, payload) { + if (!analyticsKeywordInterceptor) { + return keyword; + } + + try { + const interceptedKeyword = analyticsKeywordInterceptor({ action, keyword, payload }); + if (typeof interceptedKeyword === 'string') { + return interceptedKeyword; + } + } catch (e) { + // Keep original keyword on interceptor failure + } + + return keyword; +} + /** * Send info on search results to analytics */ @@ -40,8 +65,15 @@ export function sendSearchStats(client, keyword, numberOfResults, processingTime sendSearchStatsTimeout = setTimeout(() => { // Don't send if keyword not changed (i.e. filters changed) if (keyword !== previousKeyword) { - client.sendStatsEvent(action, keyword, { numberOfResults }); - callExternalAnalyticsCallback({ action, keyword, numberOfResults, processingTimeMs }); + const payload = { numberOfResults, processingTimeMs }; + const analyticsKeyword = getAnalyticsKeyword(action, keyword, payload); + client.sendStatsEvent(action, analyticsKeyword, { numberOfResults }); + callExternalAnalyticsCallback({ + action, + keyword: analyticsKeyword, + numberOfResults, + processingTimeMs + }); previousKeyword = keyword; searchStatsSent = true; } @@ -64,9 +96,11 @@ export function sendAutocompleteStats(keyword, statsArray) { autocompleteStatsTimeout = setTimeout(() => { // Don't send if keyword not changed (i.e. filters changed) if (keyword !== autocompletePreviousKeyword) { - statsArray.forEach((c) => - c.client.sendStatsEvent(action, keyword, { numberOfResults: c.numberOfResults }) - ); + statsArray.forEach((c, sourceIndex) => { + const payload = { numberOfResults: c.numberOfResults, sourceIndex }; + const analyticsKeyword = getAnalyticsKeyword(action, keyword, payload); + c.client.sendStatsEvent(action, analyticsKeyword, { numberOfResults: c.numberOfResults }); + }); autocompletePreviousKeyword = keyword; searchStatsSent = true; } @@ -120,9 +154,16 @@ function onLinkClick(e, client, searchResults) { documentId ); const keyword = client.getSettings().keyword; + const clickPayload = { documentId, position }; + const analyticsKeyword = getAnalyticsKeyword('click', keyword, clickPayload); - client.sendStatsEvent('click', keyword, { documentId, position }); - callExternalAnalyticsCallback({ action: 'click', keyword, documentId, position }); + client.sendStatsEvent('click', analyticsKeyword, { documentId, position }); + callExternalAnalyticsCallback({ + action: 'click', + keyword: analyticsKeyword, + documentId, + position + }); // Search stats were not sent within SEARCH_ANALYTICS_DEBOUNCE_TIME if (searchStatsSent === false) { @@ -140,9 +181,16 @@ function onLinkClick(e, client, searchResults) { ? searchResults.total_hits || searchResults.hits?.length : 0; const processingTimeMs = searchResults ? searchResults.processing_time_ms : 0; - client.sendStatsEvent('search', keyword, { numberOfResults }); + const searchPayload = { numberOfResults, processingTimeMs }; + const searchKeyword = getAnalyticsKeyword('search', keyword, searchPayload); + client.sendStatsEvent('search', searchKeyword, { numberOfResults }); searchStatsSent = true; - callExternalAnalyticsCallback({ action: 'search', keyword, numberOfResults, processingTimeMs }); + callExternalAnalyticsCallback({ + action: 'search', + keyword: searchKeyword, + numberOfResults, + processingTimeMs + }); } } diff --git a/test/util/analytics.js b/test/util/analytics.js index 90f9491..3bdeaef 100644 --- a/test/util/analytics.js +++ b/test/util/analytics.js @@ -1,7 +1,31 @@ var assert = require('assert'); var analytics = require('../../src/util/analytics'); +function withImmediateTimers(cb) { + const originalSetTimeout = global.setTimeout; + const originalClearTimeout = global.clearTimeout; + + global.setTimeout = (fn) => { + fn(); + return 1; + }; + global.clearTimeout = () => {}; + + try { + cb(); + } finally { + global.setTimeout = originalSetTimeout; + global.clearTimeout = originalClearTimeout; + } +} + describe('analytics', () => { + beforeEach(() => { + analytics.setCollectAnalytics(true); + analytics.setExternalAnalyticsCallback(null); + analytics.setAnalyticsKeywordInterceptor(null); + }); + describe('getDocumentPosition', () => { it('should return 0 if unknown', () => { const pageSize = 10; @@ -33,4 +57,61 @@ describe('analytics', () => { assert.equal(analytics.getDocumentPosition(pageSize, results, docid), expect); }); }); + + describe('analyticsKeywordInterceptor', () => { + it('should modify keyword for search analytics and callback payload', () => { + const sentEvents = []; + const callbackEvents = []; + analytics.setExternalAnalyticsCallback((data) => callbackEvents.push(data)); + analytics.setAnalyticsKeywordInterceptor(({ keyword }) => `masked:${keyword.toLowerCase()}`); + + const client = { + sendStatsEvent: (action, keyword, payload) => sentEvents.push({ action, keyword, payload }) + }; + + withImmediateTimers(() => { + analytics.sendSearchStats(client, 'My Secret Query', 12, 42); + }); + + assert.equal(sentEvents.length, 1); + assert.equal(sentEvents[0].action, 'search'); + assert.equal(sentEvents[0].keyword, 'masked:my secret query'); + assert.equal(callbackEvents.length, 1); + assert.equal(callbackEvents[0].keyword, 'masked:my secret query'); + }); + + it('should fall back to original keyword if interceptor returns non-string', () => { + const sentEvents = []; + analytics.setAnalyticsKeywordInterceptor(() => null); + + const client = { + sendStatsEvent: (action, keyword, payload) => sentEvents.push({ action, keyword, payload }) + }; + + withImmediateTimers(() => { + analytics.sendSearchStats(client, 'Original', 3, 10); + }); + + assert.equal(sentEvents.length, 1); + assert.equal(sentEvents[0].keyword, 'Original'); + }); + + it('should fall back to original keyword if interceptor throws', () => { + const sentEvents = []; + analytics.setAnalyticsKeywordInterceptor(() => { + throw new Error('boom'); + }); + + const client = { + sendStatsEvent: (action, keyword, payload) => sentEvents.push({ action, keyword, payload }) + }; + + withImmediateTimers(() => { + analytics.sendSearchStats(client, 'Fallback', 4, 11); + }); + + assert.equal(sentEvents.length, 1); + assert.equal(sentEvents[0].keyword, 'Fallback'); + }); + }); }); From 0e0684c38d5d585fe6ca7ee24620cf9bd4e65484 Mon Sep 17 00:00:00 2001 From: Hao Phan Date: Thu, 26 Mar 2026 09:39:54 +0200 Subject: [PATCH 2/2] [sc-13778] resolve SonarCloud issues --- src/util/analytics.js | 2 +- test/util/analytics.js | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/util/analytics.js b/src/util/analytics.js index af445ea..650b555 100644 --- a/src/util/analytics.js +++ b/src/util/analytics.js @@ -42,7 +42,7 @@ function getAnalyticsKeyword(action, keyword, payload) { return interceptedKeyword; } } catch (e) { - // Keep original keyword on interceptor failure + console.warn('analyticsKeywordInterceptor error:', e); } return keyword; diff --git a/test/util/analytics.js b/test/util/analytics.js index 3bdeaef..6f5cf1d 100644 --- a/test/util/analytics.js +++ b/test/util/analytics.js @@ -2,20 +2,20 @@ var assert = require('assert'); var analytics = require('../../src/util/analytics'); function withImmediateTimers(cb) { - const originalSetTimeout = global.setTimeout; - const originalClearTimeout = global.clearTimeout; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; - global.setTimeout = (fn) => { + globalThis.setTimeout = (fn) => { fn(); return 1; }; - global.clearTimeout = () => {}; + globalThis.clearTimeout = () => {}; try { cb(); } finally { - global.setTimeout = originalSetTimeout; - global.clearTimeout = originalClearTimeout; + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; } }