Search hit tracking
Implement search hit tracking to measure how users interact with search results in Optimizely Graph
Search hit tracking in Optimizely Graph lets you measure how users interact with search results by capturing search queries and result clicks. By implementing tracking at the query and click levels, you gain visibility into which results users select, how effectively search results meet user intent, and where improvements are needed. This data supports informed decisions about search tuning, content optimization, and overall user experience.
Search hit tracking records the following data:
- Search queries – Automatically tracked when you include the
trackingparameter in your query. - Search result clicks – Require client-side implementation using the
_trackfield.
Use this data to do the following:
- Identify relevant search results
- Analyze click-through rates (CTR)
- Optimize ranking based on user behavior
- Understand user intent
Search hit tracking workflow
GraphQL query setup
Add the tracking parameter to the query and request the _track.
query SearchContent($searchTerm: String!) {
Content(
where: { _fulltext: { contains: $searchTerm } }
tracking: {
phrase: $searchTerm,
source: "/search"
}
) {
items {
_track
Name
Url
}
}
}The response includes a tracking URL for each result, as shown in the following example:
{
"_track": "https://cg.optimizely.com/api/track/search/hit?tid=...&cid=...&ct=...&pos=0"
}Click tracking
Call the _track URL when a user clicks a search result. Send a GET request before or during navigation.
The tracking URL contains the following parameters:
- tid – Identifies the search session
- cid – Identifies the content item
- ct – Content type
- pos – Result position (zero-based)
Search hit tracking in single-page applications
Use a click handler to send the tracking request, as shown in the following example.
import { useState } from 'react';
function SearchResults() {
const [results, setResults] = useState([]);
// GraphQL query with tracking
async function handleSearch(searchTerm) {
const response = await fetch('https://cg.optimizely.com/content/v2', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({
query: `
query SearchContent($searchTerm: String!) {
Content(
where: { _fulltext: { contains: $searchTerm } }
tracking: { phrase: $searchTerm, source: "/search" }
) {
items {
_track
Name
Url
}
}
}
`,
variables: { searchTerm }
})
});
const data = await response.json();
setResults(data.data.Content.items);
}
// Track click and navigate
function handleResultClick(result, e) {
e.preventDefault();
// Track the click (fire-and-forget)
if (result._track) {
fetch(result._track, {
method: 'GET',
mode: 'no-cors' // Prevents CORS issues, response is opaque
}).catch(() => {
// Silently fail - tracking shouldn't break navigation
});
}
// Navigate to the result
window.location.href = result.Url;
}
return (
<div>
{results.map((result, index) => (
<a
key={index}
href={result.Url}
onClick={(e) => handleResultClick(result, e)}
>
{result.Name}
</a>
))}
</div>
);
}Search hit tracking in server-rendered applications
JavaScript click handler
Render the tracking URL as a data attribute, then attach a click event handler to send the tracking request.
Server-side code (C# example):
// Execute GraphQL query with tracking
var query = @"
query SearchContent($searchTerm: String!) {
Content(
where: { _fulltext: { contains: $searchTerm } }
tracking: { phrase: $searchTerm, source: ""/search"" }
) {
items {
_track
Name
Url
}
}
}
";
var response = await graphClient.ExecuteQueryAsync(query, new { searchTerm });
var results = response.Data.Content.Items;View/Template (Razor example):
<div class="search-results">
@foreach (var result in results)
{
<a href="@result.Url"
data-track-url="@result._track"
class="search-result-link">
@result.Name
</a>
}
</div>
<script>
// Add click tracking to all search result links
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.search-result-link').forEach(function(link) {
link.addEventListener('click', function(e) {
var trackUrl = this.getAttribute('data-track-url');
if (trackUrl) {
// Track the click (fire-and-forget)
fetch(trackUrl, {
method: 'GET',
mode: 'no-cors'
}).catch(function() {
// Silently fail
});
}
// Let the link navigate normally
});
});
});
</script>Server-side redirect endpoint
Route users through a server-side tracking endpoint that sends the tracking request, then redirects to the destination URL.
Modify result URLs to go through tracking:
@foreach (var result in results)
{
var trackingUrl = $"/go?track={Uri.EscapeDataString(result._track)}&dest={Uri.EscapeDataString(result.Url)}";
<a href="@trackingUrl">;
@result.Name
</a>
}
Create /go endpoint:
[Route("go")]
public async Task<IActionResult> TrackAndRedirect(string track, string dest)
{
if (!string.IsNullOrEmpty(track))
{
_ = Task.Run(async () =>
{
try
{
using var httpClient = new HttpClient();
await httpClient.GetAsync(track);
}
catch {}
});
}
return Redirect(dest);
}Search hit tracking best practices
Normalize search queries
Ensure consistent tracking.
function normalizeSearchTerm(term) {
return term
.trim() // Remove whitespace
.toLowerCase() // Lowercase
.replace(/\s+/g, ' '); // Collapse multiple spaces
}
const searchTerm = normalizeSearchTerm(userInput);Duplicate tracking prevention
Avoid tracking repeated searches within a five-minute window.
function shouldTrackSearch(searchTerm) {
const key = `search_tracked_${searchTerm}`;
const last = sessionStorage.getItem(key);
if (last && Date.now() - parseInt(last) < 300000) {
return false;
}
sessionStorage.setItem(key, Date.now().toString());
return true;
}Filter bot traffic
Exclude non-human interactions.
function isLikelyBot() {
const userAgent = navigator.userAgent.toLowerCase();
const botPatterns = ['bot', 'crawler', 'spider', 'headless'];
return botPatterns.some(pattern => userAgent.includes(pattern));
}
if (!isLikelyBot() && result._track) {
fetch(result._track, { mode: 'no-cors' });
}Pagination
Avoid inflating analytics:
- Track only the first page.
function executeSearch(searchTerm, page = 1) {
const shouldIncludeTracking = (page === 1);
const query = `
query SearchContent($searchTerm: String!, $skip: Int) {
Content(
where: { _fulltext: { contains: $searchTerm } }
${shouldIncludeTracking ? 'tracking: { phrase: $searchTerm }' : ''}
skip: $skip
limit: 10
) {
items {
${shouldIncludeTracking ? '_track' : ''}
Name
Url
}
}
}
`;
// Execute query...
}
- Reuse the initial tracking context across pages.
Error handling
Do not interrupt user navigation if tracking fails, as shown in the following example:
function trackSearchHit(trackUrl) {
if (!trackUrl) return;
fetch(trackUrl, {
method: 'GET',
mode: 'no-cors',
// Set a timeout to prevent hanging
signal: AbortSignal.timeout(3000) // 3 second timeout
})
.catch(err => {
// Log to your error monitoring if desired
console.debug('Hit tracking failed (non-critical):', err);
});
}Performance optimization
Use navigator.sendBeacon() when the browser supports it. This API queues the request asynchronously without blocking page navigation.
function trackSearchHit(trackUrl) {
if (!trackUrl) return;
// Use sendBeacon if available (better performance)
if (navigator.sendBeacon) {
navigator.sendBeacon(trackUrl);
} else {
// Fallback to fetch
fetch(trackUrl, {
method: 'GET',
mode: 'no-cors',
keepalive: true // Ensure request completes even if page unloads
}).catch(() => {});
}
}Complete working example
Here is a complete, production-ready React component:
import { useState, useCallback } from 'react';
// Normalize search term
function normalizeSearchTerm(term) {
return term.trim().toLowerCase().replace(/\s+/g, ' ');
}
// Track search hit with best practices
function trackSearchHit(trackUrl) {
if (!trackUrl) return;
// Don't track bots
const userAgent = navigator.userAgent.toLowerCase();
if (['bot', 'crawler', 'spider'].some(pattern => userAgent.includes(pattern))) {
return;
}
// Use sendBeacon if available
if (navigator.sendBeacon) {
navigator.sendBeacon(trackUrl);
} else {
fetch(trackUrl, {
method: 'GET',
mode: 'no-cors',
keepalive: true,
signal: AbortSignal.timeout(3000)
}).catch(() => {
console.debug('Hit tracking failed (non-critical)');
});
}
}
export function SearchResults() {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const executeSearch = useCallback(async (searchTerm) => {
const normalizedTerm = normalizeSearchTerm(searchTerm);
// Check cache to prevent duplicate tracking
const cacheKey = `search_tracked_${normalizedTerm}`;
const lastTracked = sessionStorage.getItem(cacheKey);
const shouldTrack = !lastTracked || (Date.now() - parseInt(lastTracked)) > 300000;
if (!shouldTrack) {
console.debug('Search recently tracked, skipping');
}
setLoading(true);
try {
const response = await fetch('https://cg.optimizely.com/content/v2', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.REACT_APP_GRAPH_TOKEN}`
},
body: JSON.stringify({
query: `
query SearchContent($searchTerm: String!) {
Content(
where: { _fulltext: { contains: $searchTerm } }
${shouldTrack ? 'tracking: { phrase: $searchTerm, source: "/search" }' : ''}
limit: 20
) {
items {
${shouldTrack ? '_track' : ''}
Name
Url
ContentType
}
}
}
`,
variables: { searchTerm: normalizedTerm }
})
});
const data = await response.json();
setResults(data.data.Content.items);
// Update cache
if (shouldTrack) {
sessionStorage.setItem(cacheKey, Date.now().toString());
}
} catch (error) {
console.error('Search failed:', error);
} finally {
setLoading(false);
}
}, []);
const handleResultClick = useCallback((result, event) => {
event.preventDefault();
// Track the click
trackSearchHit(result._track);
// Navigate to result
window.location.href = result.Url;
}, []);
if (loading) {
return <div>Searching...</div>;
}
return (
<div className="search-results">
{results.map((result, index) => (
<a
key={index}
href={result.Url}
onClick={(e) => handleResultClick(result, e)}
className="search-result"
>
<h3>{result.Name}</h3>
<span className="content-type">{result.ContentType}</span>
</a>
))}
</div>
);
}Updated about 13 hours ago
