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.

Prerequisites

Before you implement search hit tracking, confirm the following:

  • An Optimizely Graph account with API access through a single key or HMAC key and secret.
  • A client application that can issue GraphQL queries to the Optimizely Graph endpoint and call the returned _track URL on result clicks.

Search hit tracking workflow

The search hit tracking workflow has two phases: configure the GraphQL query to request tracking data, then send a tracking request when a user clicks a result. The phases that follow describe each step.

Configure the GraphQL query

Add the tracking parameter to the query and request the _track field.

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"
}

Track clicks

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).

  • auth – Single key for authentication (required). Use the same single key as other Content Graph API requests.

{
  "_track": "https://cg.optimizely.com/api/track/search/hit?tid=...&cid=...&ct=...&pos=0&auth=SINGLE_KEY"
}

Search hit tracking in single-page applications

In a single-page application, the page does not reload between the search request and the result click, so the click tracking call must be issued from the client before the browser navigates away. 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

In a server-rendered application, the server renders the result links as part of the page response, so the tracking URL must reach the client either through a data attribute that a script reads on click, or through a server-side redirect endpoint. The following sections describe both patterns.

JavaScript click handler

The JavaScript click handler pattern keeps the user on the original link target and fires the tracking request from the browser, which avoids an extra server round-trip on every click. 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 or 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

The server-side redirect endpoint pattern records the click on the server before the browser follows the link, which is useful when client-side script execution cannot be guaranteed (for example, when the destination is opened from an email client or a strict content-security policy blocks fetch). 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 the /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

Apply the following best practices to keep tracking data accurate, prevent inflated metrics, and avoid breaking user navigation when the tracking call fails.

Normalize search queries

Normalize the search term before sending the query so that the same user intent (for example, Laptop, laptop , and laptop) maps to a single tracked query and aggregates cleanly in reporting.

function normalizeSearchTerm(term) {
  return term
    .trim()               // Remove whitespace
    .toLowerCase()        // Lowercase
    .replace(/\s+/g, ' '); // Collapse multiple spaces
}

const searchTerm = normalizeSearchTerm(userInput);

Prevent duplicate tracking

Avoid tracking repeated searches within a five-minute window so that a single user who refines and re-runs the same query does not skew query-volume reporting.

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 so that crawler activity does not distort click-through rate (CTR) and ranking signals.

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

Track only the first page of results so that paginating through a result set does not inflate query-volume and CTR metrics.

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.

Handle errors

Do not interrupt user navigation if tracking fails. Wrap the tracking call so a network error logs to the console but never blocks the result link, 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);
  });
}

Complete working example

The following React component combines query setup, click tracking, normalization, duplicate-prevention, and bot filtering into a single production-ready reference.

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

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