Dev guideRecipesAPI ReferenceChangelog
Dev guideRecipesUser GuidesNuGetDev CommunityOptimizely AcademySubmit a ticketLog In
Dev guide

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 tracking parameter in your query.
  • Search result clicks – Require client-side implementation using the _track field.

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>
  );
}