Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -380,6 +385,10 @@ export default class AddSearchUI {
setCollectAnalytics(collect);
}

setAnalyticsKeywordInterceptor(interceptor) {
setAnalyticsKeywordInterceptor(interceptor);
}

pauseSegmentedSearch(pause) {
this.reduxStore.dispatch(setPauseSegmentedSearch(pause));
}
Expand Down
66 changes: 57 additions & 9 deletions src/util/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
console.warn('analyticsKeywordInterceptor error:', e);
}

return keyword;
}

/**
* Send info on search results to analytics
*/
Expand All @@ -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;
}
Expand All @@ -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 });
});
Comment on lines +99 to +103
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if external analytics callback is called elsewhere for autocomplete
rg -n -A5 'callExternalAnalyticsCallback' src/util/analytics.js

Repository: AddSearch/search-ui

Length of output: 695


🏁 Script executed:

#!/bin/bash
# Get the full context around lines 99-103 to understand the function
sed -n '80,120p' src/util/analytics.js

Repository: AddSearch/search-ui

Length of output: 1519


🏁 Script executed:

#!/bin/bash
# Also check the sendSearchStats function for comparison
sed -n '70,80p' src/util/analytics.js

Repository: AddSearch/search-ui

Length of output: 394


🏁 Script executed:

#!/bin/bash
# Search for the function containing the autocomplete code (lines 99-103)
rg -n 'function|const.*=.*function|\s*\w+\s*\(' src/util/analytics.js | head -20

Repository: AddSearch/search-ui

Length of output: 1115


Autocomplete analytics doesn't call external callback with intercepted keyword.

The sendAutocompleteStats function intercepts the keyword via getAnalyticsKeyword() but only sends it to c.client.sendStatsEvent(). Unlike sendSearchStats, it never calls callExternalAnalyticsCallback(), so external analytics integrations won't receive autocomplete events with the modified keyword. If this omission is intentional due to multiple sources, it should be documented; otherwise, the external callback should be invoked inside the statsArray.forEach() loop.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/util/analytics.js` around lines 99 - 103, sendAutocompleteStats currently
computes the intercepted keyword via getAnalyticsKeyword(action, keyword,
payload) and only calls c.client.sendStatsEvent, so external integrations never
receive the modified keyword; update sendAutocompleteStats inside the
statsArray.forEach loop (where c, sourceIndex, payload and analyticsKeyword are
available) to also invoke callExternalAnalyticsCallback(action,
analyticsKeyword, { numberOfResults: c.numberOfResults, sourceIndex }) (or the
existing payload shape used by sendSearchStats) so the external callback
receives the intercepted keyword for each source just like sendSearchStats does.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendAutocompleteStats has historically sent stats only via client.sendStatsEvent (per source) and not through analyticsCallback. Keeping that behavior unchanged in this PR to avoid altering callback event volume/semantics for existing integrations; we can address autocomplete callback behavior separately as an explicit enhancement.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@haoAddsearch, understood — that's a reasonable call to keep the existing sendAutocompleteStats semantics intact and avoid unexpected changes in callback event volume for existing integrations. I'll note this for future reviews.

Would you like me to open a follow-up GitHub issue to track the autocomplete external callback enhancement so it doesn't get lost?

ʕ •ᴥ•ʔ scribbles notes carefully


✏️ Learnings added
Learnt from: haoAddsearch
Repo: AddSearch/search-ui PR: 161
File: src/util/analytics.js:99-103
Timestamp: 2026-03-25T14:50:20.995Z
Learning: In `src/util/analytics.js` (AddSearch/search-ui), `sendAutocompleteStats` intentionally does NOT call `callExternalAnalyticsCallback`. It only calls `c.client.sendStatsEvent` per source. This is by design to avoid altering callback event volume/semantics for existing integrations. Adding the external callback for autocomplete is a separate planned enhancement, not a bug.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

autocompletePreviousKeyword = keyword;
searchStatsSent = true;
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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
});
}
}

Expand Down
81 changes: 81 additions & 0 deletions test/util/analytics.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
var assert = require('assert');
var analytics = require('../../src/util/analytics');

function withImmediateTimers(cb) {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;

globalThis.setTimeout = (fn) => {
fn();
return 1;
};
globalThis.clearTimeout = () => {};

try {
cb();
} finally {
globalThis.setTimeout = originalSetTimeout;
globalThis.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;
Expand Down Expand Up @@ -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');
});
});
});