Skip to main content

Overview

Tracking events at each stage of the user journey—from seeing products to clicking them—provides the data needed for analytics and recommendation improvements.

Event Flow

Search/Recs Request → Items Served → User Sees (View) → User Clicks → Add to Cart → Purchase
Each event builds on the previous, creating a complete picture of user behavior.

Tracking Search Results

Basic Implementation

const results = await refine.search.text({ query: 'summer dress', topK: 24 });

const searchContext = refine.events.trackSearch(
  'summer dress',
  results.results,
  {
    surface: 'search_results',
    totalResults: results.totalResults
  }
);

Full Parameters

const searchContext = refine.events.trackSearch(
  query: string,
  products: SearchResultItem[],
  options: {
    surface: string;      // Where products are displayed
    totalResults: number; // Total matching products
    filters?: Filter[];   // Applied filters
    sortBy?: SortBy;     // Applied sorting
  }
);

Tracking Recommendations

const recs = await refine.recs.similarItems({
  anchorId: 'sku_001',
  topK: 8
});

const recsContext = refine.events.trackRecommendations(
  recs.serveId,
  recs.results,
  'product_page',   // surface
  'similar-items',  // source
  { anchorId: 'sku_001' }  // metadata
);

Tracking Clicks

Track when a user clicks on a product:
// Basic click tracking
searchContext.trackClick(productId, position);

// Example with event handler
document.querySelectorAll('.product').forEach((el, index) => {
  el.addEventListener('click', () => {
    const productId = el.dataset.id;
    searchContext.trackClick(productId, index);
  });
});
The position parameter is 0-indexed and represents where the product appeared in the results. This is crucial for understanding if users click items at position 1 vs position 20.

Tracking Views (Viewability)

Track when a product becomes visible to the user. Standard viewability requires:
  • At least 50% of the product visible
  • Visible for at least 1 second

Using Intersection Observer

function setupViewabilityTracking(context: ServeContext) {
  const viewedProducts = new Set<string>();

  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach(entry => {
        const el = entry.target as HTMLElement;
        const productId = el.dataset.id!;
        const position = parseInt(el.dataset.position!);

        if (entry.isIntersecting && !viewedProducts.has(productId)) {
          // Start 1-second timer
          const timeout = setTimeout(() => {
            // Check if still visible
            const rect = el.getBoundingClientRect();
            const isStillVisible = (
              rect.top < window.innerHeight &&
              rect.bottom > 0
            );
            
            if (isStillVisible) {
              context.trackView(productId, position);
              viewedProducts.add(productId);
              observer.unobserve(el);
            }
          }, 1000);

          // Store timeout to clear if element leaves viewport
          el.dataset.viewTimeout = String(timeout);
        } else if (!entry.isIntersecting) {
          // Clear timeout if element leaves viewport before 1s
          const timeout = el.dataset.viewTimeout;
          if (timeout) {
            clearTimeout(parseInt(timeout));
          }
        }
      });
    },
    { threshold: 0.5 }
  );

  document.querySelectorAll('[data-id]').forEach(el => {
    observer.observe(el);
  });

  return observer;
}

Using Auto Track Plugin

For simpler implementation, use the AutoTrackPlugin:
import { AutoTrackPlugin } from '@refine-ai/sdk';

refine.use(new AutoTrackPlugin({
  viewability: {
    enabled: true,
    threshold: 0.5,   // 50% visible
    duration: 1000    // 1 second
  }
}));
See Auto Track Plugin for details.

Tracking Add to Cart

Track when a user adds a product to their cart:
// From within a serve context
searchContext.trackAddToCart(productId);

// Example implementation
document.querySelectorAll('.add-to-cart-btn').forEach(btn => {
  btn.addEventListener('click', (e) => {
    e.stopPropagation();
    const productId = btn.closest('[data-id]').dataset.id;
    
    // Track the event
    searchContext.trackAddToCart(productId);
    
    // Actually add to cart
    addToCart(productId);
  });
});

Low-Level Tracking

For custom scenarios, use trackItemsServed directly:
const context = refine.events.trackItemsServed({
  surface: 'custom_carousel',
  source: 'editorial-picks',
  itemIds: ['sku_001', 'sku_002', 'sku_003'],
  query: null,
  totalResults: 3,
  metadata: {
    carouselName: 'Summer Collection',
    placement: 'homepage-hero'
  }
});

Parameters

surface
string
required
Where products are displayed. Common values: search_results, product_page, home_page, category_page, cart_page.
source
string
required
How products were generated. Values: text-search, image-search, similar-items, visitor-recs, user-recs, plp, curated.
itemIds
string[]
required
Array of product IDs being served.
query
string
The search query, if applicable.
totalResults
number
Total available results before pagination.
metadata
object
Custom key-value pairs for additional context.

Complete Page Implementation

import { Refine } from '@refine-ai/sdk';

const refine = new Refine({
  apiKey: process.env.REFINE_API_KEY,
  organizationId: 'org_abc123',
  catalogId: 'cat_xyz789'
});

class ProductGridTracker {
  private context: any;
  private observer: IntersectionObserver | null = null;
  private viewedProducts = new Set<string>();

  async loadSearchResults(query: string) {
    const results = await refine.search.text({ query, topK: 24 });
    
    this.context = refine.events.trackSearch(query, results.results, {
      surface: 'search_results',
      totalResults: results.totalResults
    });

    this.render(results.results);
    this.setupTracking();
    
    return results;
  }

  async loadRecommendations(anchorId: string) {
    const recs = await refine.recs.similarItems({ anchorId, topK: 8 });
    
    this.context = refine.events.trackRecommendations(
      recs.serveId,
      recs.results,
      'product_page',
      'similar-items',
      { anchorId }
    );

    this.render(recs.results);
    this.setupTracking();
    
    return recs;
  }

  private render(products: any[]) {
    const container = document.getElementById('products')!;
    
    container.innerHTML = products.map((p, i) => `
      <article class="product-card" data-id="${p.productId}" data-position="${i}">
        <img src="${p.imageUrl}" alt="${p.title}" loading="lazy" />
        <h3>${p.title}</h3>
        <p class="price">$${p.price.toFixed(2)}</p>
        <button class="add-to-cart" aria-label="Add ${p.title} to cart">
          Add to Cart
        </button>
      </article>
    `).join('');
  }

  private setupTracking() {
    this.setupClickTracking();
    this.setupViewabilityTracking();
  }

  private setupClickTracking() {
    document.querySelectorAll('.product-card').forEach(card => {
      const productId = (card as HTMLElement).dataset.id!;
      const position = parseInt((card as HTMLElement).dataset.position!);

      // Product click
      card.addEventListener('click', (e) => {
        const target = e.target as HTMLElement;
        
        if (target.classList.contains('add-to-cart')) {
          this.context?.trackAddToCart(productId);
          this.handleAddToCart(productId);
        } else {
          this.context?.trackClick(productId, position);
          this.navigateToProduct(productId);
        }
      });
    });
  }

  private setupViewabilityTracking() {
    // Clean up previous observer
    this.observer?.disconnect();
    this.viewedProducts.clear();

    this.observer = new IntersectionObserver(
      (entries) => {
        entries.forEach(entry => {
          if (!entry.isIntersecting) return;
          
          const el = entry.target as HTMLElement;
          const productId = el.dataset.id!;
          const position = parseInt(el.dataset.position!);

          if (this.viewedProducts.has(productId)) return;

          // Delay to ensure genuine view
          setTimeout(() => {
            if (this.isElementVisible(el) && !this.viewedProducts.has(productId)) {
              this.context?.trackView(productId, position);
              this.viewedProducts.add(productId);
              this.observer?.unobserve(el);
            }
          }, 1000);
        });
      },
      { threshold: 0.5 }
    );

    document.querySelectorAll('.product-card').forEach(card => {
      this.observer!.observe(card);
    });
  }

  private isElementVisible(el: Element): boolean {
    const rect = el.getBoundingClientRect();
    return rect.top < window.innerHeight && rect.bottom > 0;
  }

  private handleAddToCart(productId: string) {
    // Your add to cart logic
  }

  private navigateToProduct(productId: string) {
    window.location.href = `/products/${productId}`;
  }

  destroy() {
    this.observer?.disconnect();
  }
}

// Usage
const tracker = new ProductGridTracker();
await tracker.loadSearchResults('summer dress');

Best Practices

Do:
  • Track clicks immediately on click, not on navigation complete
  • Track views with a 1-second delay to filter accidental scrolls
  • Track add-to-cart before the actual cart operation
  • Use consistent surface and source values across your app
Don’t:
  • Track views on render (before user could actually see them)
  • Track clicks multiple times for the same interaction
  • Forget to track interactions from keyboard navigation
  • Block user interactions while waiting for tracking to complete

Next Steps