General Website Setup
For most websites, copy the snippet below and paste it into the <head> section of your HTML pages, ideally before any other scripts.
Loading general snippet...

Test Your Installation

Use this tool to perform a live test and verify that your Friction Sentry snippet is correctly installed on your website and can communicate with our servers.

Step 1: Enter Your Site's URL

Enter the full URL of a page on your site where you have installed the snippet. For example: `https://www.your-store.com/products/example`

Step 2: Verify Connection

Clicking "Verify" will open your URL in a popup. The snippet on that page will send a test event. Please ensure your browser does not block popups.

IMPORTANT: Solving "TypeError: Failed to fetch" or "CORS error"

Do I need to modify my website's Content Security Policy (CSP)?

This step is usually only needed if your website has a strict Content Security Policy (CSP). Many websites do not require this change.

  • Install the snippet above.
  • Test your website. Are you seeing "TypeError: Failed to fetch", "CORS error", or "CSP" related errors in your **EXTERNAL WEBSITE's** developer console when interacting with your site?
  • Are events from your external website appearing on your Friction Sentry Dashboard?

If you answered **NO** to errors and **YES** to events appearing, you likely **DO NOT** need to modify your CSP. Your setup is working!

However, if you *do* encounter such errors, or if you know your website employs a strict CSP, the following guidance on updating the connect-src directive is crucial.

If you see errors like "TypeError: Failed to fetch" or "CORS error" in your EXTERNAL WEBSITE's console, AND your browser's Network tab shows the OPTIONS preflight request succeeding (Status 200 or 204) BUT the subsequent POST request fails, the problem is almost certainly your EXTERNAL WEBSITE's Content Security Policy (CSP).

WHAT YOU NEED TO ADD (to your EXTERNAL website's CSP settings):

You must add the following URL (the origin of *this* Friction Sentry application) to theconnect-src directive of yourEXTERNAL WEBSITE's Content Security Policy:

YOUR_FRICTION_SENTRY_APP_ORIGIN_WILL_LOAD_HERE

(If the above is blank, please ensure your browser allows JavaScript and refresh this page. This URL is dynamically determined by your browser's current location.)

HOW TO MODIFY CSP (General Guidance):

  1. Find where your website's Content-Security-Policy HTTP header is set (e.g., server config, hosting platform settings, meta tag).
  2. Locate the connect-src directive. If it doesn't exist, you might add it. Example: if connect-src 'self' https://some-analytics.com;Change to: connect-src 'self' https://some-analytics.com YOUR_FRICTION_SENTRY_APP_ORIGIN;

Note: Snippet uses navigator.sendBeacon() with a fetch() fallback.

Shopify Integration (Recommended for Shopify Stores)
To integrate Friction Sentry with your Shopify store, you'll need to create a dedicated **Shopify App** in your Shopify Partner Dashboard. This app acts as the bridge between your store and this Friction Sentry service. This is the standard, secure way to build Shopify integrations and it avoids many common cross-domain (CORS) issues for the merchant.

This Friction Sentry application (where you are now) is the backend service and dashboard. The "Shopify App" you create in the Partner Dashboard will handle the installation and authentication on your Shopify store, and will be what merchants install from the Shopify App Store.

Step 1: Configure Your Shopify App in Partner Dashboard

When setting up your new app in the Shopify Partner Dashboard, you must provide a few key URLs that point to *this deployed Friction Sentry application*:

  • App URL: This is where merchants are sent after installing your app. It's your app's main interface.
    Set this to: https://your-deployed-frictionsentry-app.com
    (This should be the main URL of this Friction Sentry application.)
  • Allowed redirection URL(s): This is a security-critical step for Shopify's OAuth (authentication) flow. After a merchant authorizes your app, Shopify will only redirect them to a URL on this list.
    Add this exact URL: https://.../api/auth/shopify/callback
    (This Friction Sentry application has an endpoint at `/api/auth/shopify/callback` to handle the authentication handshake.)

(If is blank, refresh the page. For a live app, ensure this reflects your deployed Friction Sentry application's domain.)

Step 2: Configure Shopify App Proxy

The App Proxy is the key to bypassing browser CORS issues for event collection. Within your Shopify App's settings, navigate to "App proxy". Configure it as follows:

  • Subpath prefix: apps
  • Subpath: frictionsentryproxy (This must match the API_ENDPOINT in the snippet below.)
  • Proxy URL: This must point to this Friction Sentry application's Shopify proxy handler endpoint.
    Set this to: https://.../api/shopify-proxy-handler

This tells Shopify to forward requests from your-store.myshopify.com/apps/frictionsentryproxy to your handler at /api/shopify-proxy-handler.

Step 3: Add Snippet to Your Shopify Theme

Your dedicated Shopify App (which you build separately) should inject this snippet automatically into the merchant's theme upon installation using Shopify's Theme App Embeds or the ScriptTag API.

<script>
(function() {
  // For Shopify, the API_ENDPOINT is relative to the merchant's store,
  // pointing to the path you configure in your Shopify App Proxy settings.
  const API_ENDPOINT = '/apps/frictionsentryproxy'; // Ensure this matches your App Proxy config

  function reportFrictionEvent(eventData) {
    const eventPayload = {
      ...eventData,
      timestamp: new Date().toISOString(),
      url: window.location.href,
      userAgent: navigator.userAgent,
    };

    const jsonData = JSON.stringify(eventPayload);

    // Use sendBeacon if available and reliable, otherwise fallback to fetch with keepalive.
    // This makes it more robust for events sent during page unload.
    try {
      if (navigator.sendBeacon) {
        // Create a blob to send. sendBeacon is more reliable with blobs.
        const blob = new Blob([jsonData], { type: 'application/json' });
        if (navigator.sendBeacon(API_ENDPOINT, blob)) {
          // Beacon was queued successfully.
          return;
        }
      }
    } catch (e) {
      // sendBeacon can throw errors in some strict security environments.
      console.error('FrictionSentry (Shopify): sendBeacon error, falling back to fetch.', e);
    }
    
    // Fallback to fetch for browsers that don't support sendBeacon or if queueing failed.
    sendWithFetch(jsonData);
  }

  function sendWithFetch(payloadBodyString) {
    try {
      fetch(API_ENDPOINT, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: payloadBodyString,
        keepalive: true, // Crucial for requests that might outlive the page.
      })
      .then(response => {
        if (!response.ok) {
          response.json().then(errData => {
            console.error('FrictionSentry (Shopify): Failed to report event (fetch) -', response.status, errData.message || response.statusText);
          }).catch(() => {
            console.error('FrictionSentry (Shopify): Failed to report event (fetch) -', response.status, response.statusText);
          });
        }
      })
      .catch(error => console.error('FrictionSentry (Shopify): Error reporting event (fetch) -', error));
    } catch (e) {
       console.error('FrictionSentry (Shopify): Error reporting event (fetch) -', e)
    }
  }

  // --- Verification Logic ---
  // This part is the same as the general snippet, but now uses the robust reporting function.
  try {
    const currentUrl = new URL(window.location.href);
    const isVerification = currentUrl.searchParams.get('fs_verify') === 'true';
    const verificationId = currentUrl.searchParams.get('fs_vid');
    
    if (isVerification && verificationId) {
      reportFrictionEvent({
        type: 'verificationTest',
        meta: { verificationId: verificationId }
      });
    }
  } catch(e) {
    // Silently fail if URL parsing fails.
  }
  // --- End Verification Logic ---


  window.addEventListener('error', function(event) {
    reportFrictionEvent({
      type: 'jsError',
      message: event.message,
      stackTrace: event.error && event.error.stack ? event.error.stack : (event.filename ? (event.filename + ':' + event.lineno + ':' + event.colno) : 'No stack available'),
    });
  });

  function getCssSelector(el) {
    if (!(el instanceof Element)) return 'not-an-element';
    if (!document.body.contains(el)) return 'element-not-in-dom';
    
    const path = [];
    let currentEl = el;
    while (currentEl && currentEl.nodeType === Node.ELEMENT_NODE) {
      let selector = currentEl.nodeName.toLowerCase();
      if (currentEl.id) {
        const id = currentEl.id.replace(/[!"#$%&'()*+,./:;<=>?@[\]^\`{|}~]/g, "\\$&");
        selector += '#' + id;
        path.unshift(selector);
        break;
      } else {
        let sib = currentEl, nth = 1;
        let foundMatchingSibling = false;
        while (sib = sib.previousElementSibling) {
          if (sib.nodeName.toLowerCase() === selector) nth++;
        }
        if (nth > 1) {
            selector += ":nth-of-type("+nth+")";
        } else {
            sib = currentEl.nextElementSibling;
            while(sib){
                if(sib.nodeName.toLowerCase() === selector){
                    foundMatchingSibling = true;
                    break;
                }
                sib = sib.nextElementSibling;
            }
            if(foundMatchingSibling && nth === 1){ 
                 selector += ":nth-of-type("+nth+")";
            }
        }
      }
      path.unshift(selector);
      if (!currentEl.parentElement) break;
      currentEl = currentEl.parentElement;
    }
    return path.length ? path.join(" > ") : 'selector-unavailable';
  }
  
  let lastClickedElementDetails = { element: null, time: 0, count: 0 };
  const RAGE_CLICK_COUNT_THRESHOLD = 3;
  const RAGE_CLICK_WINDOW_MS_THRESHOLD = 1000; 
  const DEAD_CLICK_TIMEOUT_MS_THRESHOLD = 2000;

  document.addEventListener('click', function(event) {
    const clickedElement = event.target;
    if (!(clickedElement instanceof Element)) return;

    const currentTime = Date.now();
    const cssSelector = getCssSelector(clickedElement);

    if (lastClickedElementDetails.element === clickedElement && (currentTime - lastClickedElementDetails.time) < RAGE_CLICK_WINDOW_MS_THRESHOLD) {
      lastClickedElementDetails.count++;
    } else {
      lastClickedElementDetails.element = clickedElement;
      lastClickedElementDetails.count = 1;
    }
    lastClickedElementDetails.time = currentTime;

    if (lastClickedElementDetails.count >= RAGE_CLICK_COUNT_THRESHOLD) {
      reportFrictionEvent({ type: 'rageClick', selector: cssSelector });
      lastClickedElementDetails.count = 0; 
    }

    const isPotentiallyInteractiveElement = clickedElement.tagName === 'A' || clickedElement.tagName === 'BUTTON' || clickedElement.closest('a, button') || clickedElement.getAttribute('role') === 'button' || window.getComputedStyle(clickedElement).cursor === 'pointer';
    if (isPotentiallyInteractiveElement) {
      const initialHref = window.location.href;
      let navigationOrMutationOccurred = false;

      const mutationObs = new MutationObserver(() => {
        if(!navigationOrMutationOccurred) {
            navigationOrMutationOccurred = true;
            clearTimeout(deadClickCheckTimer); 
            mutationObs.disconnect();
        }
      });
      mutationObs.observe(document.documentElement, { childList: true, subtree: true, attributes: true });

      const handleBeforeUnload = () => {
        if(!navigationOrMutationOccurred) { 
            navigationOrMutationOccurred = true;
            clearTimeout(deadClickCheckTimer); 
            mutationObs.disconnect(); 
        }
      };
      window.addEventListener('beforeunload', handleBeforeUnload, { once: true });
      
      const deadClickCheckTimer = setTimeout(() => {
        mutationObs.disconnect(); 
        window.removeEventListener('beforeunload', handleBeforeUnload); 
        if (!navigationOrMutationOccurred && window.location.href === initialHref) {
          reportFrictionEvent({ type: 'deadClick', selector: cssSelector });
        }
      }, DEAD_CLICK_TIMEOUT_MS_THRESHOLD);
    }
  }, true); 

  console.log('Friction Sentry Analytics (Shopify) Loaded.');
})();
</script>

This Shopify-specific snippet sends data to a relative path (/apps/frictionsentryproxy). Shopify then proxies this data to your server, which generally avoids direct cross-domain browser requests and associated CSP complexities for the merchant.