From 10e46d4fc55e9cc521e75bca88dacb0e3f194e43 Mon Sep 17 00:00:00 2001 From: velixio <270455167+velixio@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:56:26 +0530 Subject: [PATCH 1/4] tracker: send events with fetch(keepalive) instead of sendBeacon Blockers classify beacons as "ping" requests, which filter lists block wholesale. fetch with keepalive also survives page exit, and a string body keeps it a simple request without a preflight. --- internal/tracker/tracker.full.min.js | 2 +- internal/tracker/tracker.js | 9 ++++++--- internal/tracker/tracker.min.js | 2 +- internal/tracker/tracker.runtime.min.js | 2 +- internal/tracker/tracker_runtime_test.js | 15 ++++++++++----- internal/tracker/tracker_test.go | 1 - 6 files changed, 19 insertions(+), 12 deletions(-) diff --git a/internal/tracker/tracker.full.min.js b/internal/tracker/tracker.full.min.js index 7c25fd19..5ea2967d 100644 --- a/internal/tracker/tracker.full.min.js +++ b/internal/tracker/tracker.full.min.js @@ -1 +1 @@ -(()=>{var e,t,s,o,i,a,r,c,l,u,m,f,p,g,v,_,C=document.currentScript,n=C.dataset;if(!n.site||navigator.webdriver||!C.hasAttribute("data-allow-localhost")&&/(^|\.)localhost$|^127\.|^\[::1\]$/.test(location.hostname))return;o=n.manual=="true",c=!o,g=location.href,f=n.api||"/api/public/v1/events";try{_=localStorage.supermetry_ignore}catch{}m=location.href,l=0,s=o?0:document.hidden?0:Date.now(),r=0,a=!o&&!0;function b(){return!_&&!navigator.globalPrivacyControl&&navigator.doNotTrack!="1"&&window.doNotTrack!="1"&&!u&&!t}function y(e){if(!b())return;e=JSON.stringify(e),navigator.sendBeacon?.(f,e)||fetch(f,{method:"POST",body:e,keepalive:!0}).catch(()=>0)}function k(e){return n.hash=="true"?e:e.split("#")[0]}function A(t){var s,o=e.beforeSend||window[n.beforeSend];if(typeof o!="function")return t;try{s=o({name:t.name,url:t.url,referrer:t.referrer,props:t.props,engagement_ms:t.engagement_ms,scroll_pct:t.scroll_pct})}catch{return}if(!s||typeof s!="object")return;return["name","url","referrer","props","engagement_ms","scroll_pct"].forEach(e=>{e in s&&(t[e]=s[e])}),t}v=n.consent=="required",t=v?[]:0;function w(e){if(u)return;if(t){if(t.length==20){var n=t.findIndex(e=>e.name!="pageview");t.splice(n<0?0:n,1)}t.push(e);return}e=A(e),e&&y(e)}function d(e,t,s,o,a,r){var c={v:1,ts:Date.now(),id:(crypto.randomUUID?.()||Math.random().toString(16)).replace(/\W/g,""),site:n.site,name:e,url:m,referrer:document.referrer,screen_width:screen.width,props:t||{},engagement_ms:s||0,scroll_pct:o||0,traits:r};a&&(c.revenue=a),i&&(c.profile_id=i),w(c);return;y(c)}function h(){if(!c)return;s&&(l+=Date.now()-s,s=0),O(),a&&(d("engagement",{},Math.round(l),r),l=r=0,a=!1)}function O(){var e=Math.max(document.documentElement.scrollHeight,document.body?.scrollHeight||0);e&&(r=Math.max(r,Math.min(100,Math.round(100*(scrollY+innerHeight)/e))))}function x(e,t){c=!0,g=location.href,m=e||location.href,l=r=0,s=document.hidden?0:Date.now(),a=!0,d("pageview",t)}function j(){if(o||k(location.href)==k(g))return;e._nav.forEach(e=>e()),h(),x()}function E(e){var t=history[e];history[e]=(...e)=>{t.apply(history,e),j()}}E("pushState"),addEventListener("popstate",j),n.hash=="true"&&addEventListener("hashchange",j),E("replaceState"),addEventListener("scroll",()=>{if(!c)return;O(),a=!0},{passive:!0}),document.addEventListener("visibilitychange",()=>c?document.hidden?h():(s=Date.now(),a=!0):0),addEventListener("pagehide",h),e=window.supermetry=window.supermetry||{},e._nav=[],e._post=y,e._allowed=b,p=n=>()=>{_=n,n&&(i=0,t&&(t=[]),e._clearHeatmap?.());try{n?localStorage.supermetry_ignore=1:delete localStorage.supermetry_ignore}catch{}e._privacyState=!n,e._privacy?.(!n)},e.optOut=p(1),e.optIn=p(0),e.pageview=e=>{var t=e?.url;try{t=t?new URL(t,location.href).href:location.href}catch{return}h(),x(t,e?.props)},e.consent=n=>{if(u=!n,n||(i=0,e._clearHeatmap?.()),e._privacyState=!!n,e._privacy?.(!!n),!v)return;var s=t;u=!n,t=n?0:[],n&&s&&s.forEach(w)},e.track=(e,t,n)=>d(e,t,0,0,n?.revenue),e.identify=(e,t={})=>{if(n.profiles=="false"||!b())return;if(e===null)return i=0;if(e!==String(e)||!e||e.length>128||!t||typeof t!="object"||Array.isArray(t))return;var s,o=0;for(s in t)if(++o>20||!s.length||s.length>64||typeof t[s]!="string"||t[s].length>256)return;i=e,d("identify",0,0,0,0,t)},o||d("pageview")})(),(()=>{var e,o,i,l,d,u,h,f=document.currentScript,t=f.dataset,s=window.supermetry,n=s?.track;if(!n)return;function j(e){if(e.button>1)return;var o,i,s=e.target.closest?.("a");if(!s)return;o={url:(s.origin+s.pathname+s.hash).slice(0,500)},t.outbound!="false"&&s.protocol.indexOf("http")==0&&s.host!=location.host&&n("Outbound Link: Click",o),i=(t.downloadExt||"pdf,xlsx,docx,csv,txt,zip,rar,7z,gz,dmg,exe,msi,pkg,apk,mp3,mp4,mov,avi,wav,key,pptx,ppt,xls,doc,epub,iso").toLowerCase().split(","),t.downloads!="false"&&i.indexOf(s.pathname.toLowerCase().split(".").pop())>=0&&n("File Download",o)}function v(e){if(t.clicks=="false")return;for(s=e.target,r=0;s&&r<4;r++,s=s.parentElement){if(c=s.getAttribute?.("data-supermetry-event"),c){for(var s,o,i,a,r,c,u,l={},d=0,h=0,m=0;d700)break;l[i]=u,h++}n(c.slice(0,75),l);return}if(a=g(s),a){n(a.name.slice(0,75),a.props);return}}}function g(e){var t,n,s,i,o={},a=0;for(i of e.classList||[]){if(i.indexOf("supermetry-event-")!=0)continue;n=i.slice(17).replace(/\+/g," "),t=n.indexOf("="),t<0?s=s||n:t&&t<=64&&n.length-t-1<=256&&++a<=20&&(o[n.slice(0,t)]=n.slice(t+1))}return s&&{name:s,props:o}}if(document.addEventListener("click",j),document.addEventListener("auxclick",j),document.addEventListener("click",v),document.addEventListener("submit",v),t.forms!="false"&&document.addEventListener("submit",e=>{var s,t=e.target;if(!t||t.tagName!="FORM"||t.getAttribute?.("data-supermetry-event")||g(t))return;s=(t.getAttribute("action")||"").split(/[?#]/)[0].slice(0,500),n("Form: Submit",{form_id:(t.getAttribute("id")||"").slice(0,100),form_name:(t.getAttribute("name")||"").slice(0,100),action:s})},!0),t[404]!="false"&&(f.hasAttribute("data-404-page")||document.querySelector('meta[name="supermetry:404"]'))&&n("404",{path:location.pathname.slice(0,500)}),t.vitals!="false"&&window.PerformanceObserver){function p(t,n){for(var s,o=0;o=1e3||s.startTime-u>=5e3)&&(u=s.startTime,i=0),i+=s.value,d=s.startTime,l=Math.max(l,i),e.cls=""+Math.round(l*1e3)}else t.kind==4?s.name=="first-contentful-paint"&&(e.fcp=""+Math.round(s.startTime)):s.interactionId&&(e.inp=""+Math.max(e.inp||0,Math.round(s.duration)))}function c(e,t,n){try{var s=new PerformanceObserver(e=>p(s,e.getEntries()));return s.kind=t,s.observe({type:e,buffered:n,durationThreshold:40}),o.push(s),s}catch{}}function r(e){o.forEach((t,n)=>{if(!t||e&&t.kind!=e)return;t.takeRecords&&p(t,t.takeRecords()),t.disconnect(),o[n]=0})}function b(t){if(o&&r(),o=[],e={},l=u=d=i=0,h=!1,!t){c("largest-contentful-paint",1,!0),c("paint",4,!0);try{var n=performance.getEntriesByType("navigation")[0],s=n&&n.responseStart-n.startTime;s>=0&&(e.ttfb=""+Math.round(s))}catch{}}c("layout-shift",2,!t),c("event",3,!t)}function m(){if(h)return;h=!0,r(),(e.lcp||e.cls||e.inp)&&n("Web Vitals",e)}document.addEventListener("pointerdown",()=>r(1),!0),document.addEventListener("keydown",()=>r(1),!0),document.addEventListener("visibilitychange",()=>document.hidden&&m()),addEventListener("pagehide",m),s._nav.push(()=>{m(),b(1)}),b()}if(t.heatmap=="true"){let e=[],n=location.href;s._clearHeatmap=()=>{e=[]};const o=document.documentElement;function a(e,t){return Math.max(0,Math.min(t,Math.round(e)))}function _(t){if(!s._allowed()){e=[];return}const n=o.scrollWidth||1;e.length<200&&e.push([a(t.pageX*1e3/n,1e3),a(t.pageY,1e5)])}function y(){if(!s._allowed()){e=[];return}if(!e.length)return;const i={site:t.site,name:"heatmap",url:n,screen_width:screen.width,clicks:e,doc_h:a(o.scrollHeight,1e5),vw:a(innerWidth,1e4)};e=[],s._post(i)}document.addEventListener("click",_),addEventListener("pagehide",y),s._nav.push(()=>{y(),n=location.href})}})(),(()=>{var n,o=document.currentScript,t=o.dataset,e=window.supermetry;if(!e||!t.replay)return;n=e._privacyState??(t.replay=="never"||t.replay=="auto"&&t.consent!="required");function s(){var s,o,i,a,r,c,l=navigator.connection;if(!n||!e._allowed()||e._replay||l?.saveData||/^(slow-)?2g$/.test(l?.effectiveType))return;s=new URL(t.api||"/api/public/v1/events",location.href),i=s.pathname.match(/^(.*)\/api\/public\/v1\/events\/?$/),a=s.origin+(i?i[1]:""),r=s.pathname.replace(/\/$/,""),o=r.endsWith("/e/"+t.site)?s.origin+r:"",e._replay={s:t.site,o:a,p:o},c=document.createElement("script"),c.src=o?o+"/r.js":a+"/replay.js",document.head.appendChild(c)}e.replay={trigger:t=>e._replayTrigger=t||"manual"},e._privacy=t=>{n=t,e._replayConsent=t,e._replayDenied=!t,t?s():e._replayStop?.()},n&&s()})() \ No newline at end of file +(()=>{var e,t,s,o,i,a,r,c,l,u,m,f,p,g,v,w,C=document.currentScript,n=C.dataset;if(!n.site||navigator.webdriver||!C.hasAttribute("data-allow-localhost")&&/(^|\.)localhost$|^127\.|^\[::1\]$/.test(location.hostname))return;o=n.manual=="true",c=!o,g=location.href,w=n.api||"/api/public/v1/events";try{f=localStorage.supermetry_ignore}catch{}m=location.href,l=0,s=o?0:document.hidden?0:Date.now(),r=0,a=!o&&!0;function b(){return!f&&!navigator.globalPrivacyControl&&navigator.doNotTrack!="1"&&window.doNotTrack!="1"&&!u&&!t}function y(e){if(!b())return;e=JSON.stringify(e),fetch(w,{method:"POST",body:e,keepalive:!0}).catch(()=>0)}function k(e){return n.hash=="true"?e:e.split("#")[0]}function A(t){var s,o=e.beforeSend||window[n.beforeSend];if(typeof o!="function")return t;try{s=o({name:t.name,url:t.url,referrer:t.referrer,props:t.props,engagement_ms:t.engagement_ms,scroll_pct:t.scroll_pct})}catch{return}if(!s||typeof s!="object")return;return["name","url","referrer","props","engagement_ms","scroll_pct"].forEach(e=>{e in s&&(t[e]=s[e])}),t}v=n.consent=="required",t=v?[]:0;function _(e){if(u)return;if(t){if(t.length==20){var n=t.findIndex(e=>e.name!="pageview");t.splice(n<0?0:n,1)}t.push(e);return}e=A(e),e&&y(e)}function d(e,t,s,o,a,r){var c={v:1,ts:Date.now(),id:(crypto.randomUUID?.()||Math.random().toString(16)).replace(/\W/g,""),site:n.site,name:e,url:m,referrer:document.referrer,screen_width:screen.width,props:t||{},engagement_ms:s||0,scroll_pct:o||0,traits:r};a&&(c.revenue=a),i&&(c.profile_id=i),_(c);return;y(c)}function h(){if(!c)return;s&&(l+=Date.now()-s,s=0),O(),a&&(d("engagement",{},Math.round(l),r),l=r=0,a=!1)}function O(){var e=Math.max(document.documentElement.scrollHeight,document.body?.scrollHeight||0);e&&(r=Math.max(r,Math.min(100,Math.round(100*(scrollY+innerHeight)/e))))}function x(e,t){c=!0,g=location.href,m=e||location.href,l=r=0,s=document.hidden?0:Date.now(),a=!0,d("pageview",t)}function j(){if(o||k(location.href)==k(g))return;e._nav.forEach(e=>e()),h(),x()}function E(e){var t=history[e];history[e]=(...e)=>{t.apply(history,e),j()}}E("pushState"),addEventListener("popstate",j),n.hash=="true"&&addEventListener("hashchange",j),E("replaceState"),addEventListener("scroll",()=>{if(!c)return;O(),a=!0},{passive:!0}),document.addEventListener("visibilitychange",()=>c?document.hidden?h():(s=Date.now(),a=!0):0),addEventListener("pagehide",h),e=window.supermetry=window.supermetry||{},e._nav=[],e._post=y,e._allowed=b,p=n=>()=>{f=n,n&&(i=0,t&&(t=[]),e._clearHeatmap?.());try{n?localStorage.supermetry_ignore=1:delete localStorage.supermetry_ignore}catch{}e._privacyState=!n,e._privacy?.(!n)},e.optOut=p(1),e.optIn=p(0),e.pageview=e=>{var t=e?.url;try{t=t?new URL(t,location.href).href:location.href}catch{return}h(),x(t,e?.props)},e.consent=n=>{if(u=!n,n||(i=0,e._clearHeatmap?.()),e._privacyState=!!n,e._privacy?.(!!n),!v)return;var s=t;u=!n,t=n?0:[],n&&s&&s.forEach(_)},e.track=(e,t,n)=>d(e,t,0,0,n?.revenue),e.identify=(e,t={})=>{if(n.profiles=="false"||!b())return;if(e===null)return i=0;if(e!==String(e)||!e||e.length>128||!t||typeof t!="object"||Array.isArray(t))return;var s,o=0;for(s in t)if(++o>20||!s.length||s.length>64||typeof t[s]!="string"||t[s].length>256)return;i=e,d("identify",0,0,0,0,t)},o||d("pageview")})(),(()=>{var e,o,i,l,d,u,h,f=document.currentScript,t=f.dataset,s=window.supermetry,n=s?.track;if(!n)return;function j(e){if(e.button>1)return;var o,i,s=e.target.closest?.("a");if(!s)return;o={url:(s.origin+s.pathname+s.hash).slice(0,500)},t.outbound!="false"&&s.protocol.indexOf("http")==0&&s.host!=location.host&&n("Outbound Link: Click",o),i=(t.downloadExt||"pdf,xlsx,docx,csv,txt,zip,rar,7z,gz,dmg,exe,msi,pkg,apk,mp3,mp4,mov,avi,wav,key,pptx,ppt,xls,doc,epub,iso").toLowerCase().split(","),t.downloads!="false"&&i.indexOf(s.pathname.toLowerCase().split(".").pop())>=0&&n("File Download",o)}function v(e){if(t.clicks=="false")return;for(s=e.target,r=0;s&&r<4;r++,s=s.parentElement){if(c=s.getAttribute?.("data-supermetry-event"),c){for(var s,o,i,a,r,c,u,l={},d=0,h=0,m=0;d700)break;l[i]=u,h++}n(c.slice(0,75),l);return}if(a=g(s),a){n(a.name.slice(0,75),a.props);return}}}function g(e){var t,n,s,i,o={},a=0;for(i of e.classList||[]){if(i.indexOf("supermetry-event-")!=0)continue;n=i.slice(17).replace(/\+/g," "),t=n.indexOf("="),t<0?s=s||n:t&&t<=64&&n.length-t-1<=256&&++a<=20&&(o[n.slice(0,t)]=n.slice(t+1))}return s&&{name:s,props:o}}if(document.addEventListener("click",j),document.addEventListener("auxclick",j),document.addEventListener("click",v),document.addEventListener("submit",v),t.forms!="false"&&document.addEventListener("submit",e=>{var s,t=e.target;if(!t||t.tagName!="FORM"||t.getAttribute?.("data-supermetry-event")||g(t))return;s=(t.getAttribute("action")||"").split(/[?#]/)[0].slice(0,500),n("Form: Submit",{form_id:(t.getAttribute("id")||"").slice(0,100),form_name:(t.getAttribute("name")||"").slice(0,100),action:s})},!0),t[404]!="false"&&(f.hasAttribute("data-404-page")||document.querySelector('meta[name="supermetry:404"]'))&&n("404",{path:location.pathname.slice(0,500)}),t.vitals!="false"&&window.PerformanceObserver){function p(t,n){for(var s,o=0;o=1e3||s.startTime-u>=5e3)&&(u=s.startTime,i=0),i+=s.value,d=s.startTime,l=Math.max(l,i),e.cls=""+Math.round(l*1e3)}else t.kind==4?s.name=="first-contentful-paint"&&(e.fcp=""+Math.round(s.startTime)):s.interactionId&&(e.inp=""+Math.max(e.inp||0,Math.round(s.duration)))}function c(e,t,n){try{var s=new PerformanceObserver(e=>p(s,e.getEntries()));return s.kind=t,s.observe({type:e,buffered:n,durationThreshold:40}),o.push(s),s}catch{}}function r(e){o.forEach((t,n)=>{if(!t||e&&t.kind!=e)return;t.takeRecords&&p(t,t.takeRecords()),t.disconnect(),o[n]=0})}function b(t){if(o&&r(),o=[],e={},l=u=d=i=0,h=!1,!t){c("largest-contentful-paint",1,!0),c("paint",4,!0);try{var n=performance.getEntriesByType("navigation")[0],s=n&&n.responseStart-n.startTime;s>=0&&(e.ttfb=""+Math.round(s))}catch{}}c("layout-shift",2,!t),c("event",3,!t)}function m(){if(h)return;h=!0,r(),(e.lcp||e.cls||e.inp)&&n("Web Vitals",e)}document.addEventListener("pointerdown",()=>r(1),!0),document.addEventListener("keydown",()=>r(1),!0),document.addEventListener("visibilitychange",()=>document.hidden&&m()),addEventListener("pagehide",m),s._nav.push(()=>{m(),b(1)}),b()}if(t.heatmap=="true"){let e=[],n=location.href;s._clearHeatmap=()=>{e=[]};const o=document.documentElement;function a(e,t){return Math.max(0,Math.min(t,Math.round(e)))}function _(t){if(!s._allowed()){e=[];return}const n=o.scrollWidth||1;e.length<200&&e.push([a(t.pageX*1e3/n,1e3),a(t.pageY,1e5)])}function y(){if(!s._allowed()){e=[];return}if(!e.length)return;const i={site:t.site,name:"heatmap",url:n,screen_width:screen.width,clicks:e,doc_h:a(o.scrollHeight,1e5),vw:a(innerWidth,1e4)};e=[],s._post(i)}document.addEventListener("click",_),addEventListener("pagehide",y),s._nav.push(()=>{y(),n=location.href})}})(),(()=>{var n,o=document.currentScript,t=o.dataset,e=window.supermetry;if(!e||!t.replay)return;n=e._privacyState??(t.replay=="never"||t.replay=="auto"&&t.consent!="required");function s(){var s,o,i,a,r,c,l=navigator.connection;if(!n||!e._allowed()||e._replay||l?.saveData||/^(slow-)?2g$/.test(l?.effectiveType))return;s=new URL(t.api||"/api/public/v1/events",location.href),i=s.pathname.match(/^(.*)\/api\/public\/v1\/events\/?$/),a=s.origin+(i?i[1]:""),r=s.pathname.replace(/\/$/,""),o=r.endsWith("/e/"+t.site)?s.origin+r:"",e._replay={s:t.site,o:a,p:o},c=document.createElement("script"),c.src=o?o+"/r.js":a+"/replay.js",document.head.appendChild(c)}e.replay={trigger:t=>e._replayTrigger=t||"manual"},e._privacy=t=>{n=t,e._replayConsent=t,e._replayDenied=!t,t?s():e._replayStop?.()},n&&s()})() \ No newline at end of file diff --git a/internal/tracker/tracker.js b/internal/tracker/tracker.js index 75e4ff66..d27fbccc 100644 --- a/internal/tracker/tracker.js +++ b/internal/tracker/tracker.js @@ -39,11 +39,14 @@ function post(payload) { if (!permitted()) return; payload = JSON.stringify(payload); - navigator.sendBeacon?.(endpoint, payload) || fetch(endpoint, { + // fetch(keepalive) survives page exit like sendBeacon, but blockers file + // it as a regular request instead of a "ping", which lists block + // wholesale. A string body keeps it a simple request: no preflight. + fetch(endpoint, { method: "POST", body: payload, - keepalive: true - }).catch(() => 0); + keepalive: true + }).catch(() => 0); } function key(url) { diff --git a/internal/tracker/tracker.min.js b/internal/tracker/tracker.min.js index 217bf22d..427c7a0c 100644 --- a/internal/tracker/tracker.min.js +++ b/internal/tracker/tracker.min.js @@ -1 +1 @@ -(()=>{var t,n,s,i,a,c,d,u,h,m,f,p,r=document.currentScript,e=r.dataset;if(!e.site||navigator.webdriver||!r.hasAttribute("data-allow-localhost")&&/(^|\.)localhost$|^127\.|^\[::1\]$/.test(location.hostname))return;if(e.consent=="required"||e.manual=="true"||e.beforeSend){try{m=r.cloneNode(),m.src=r.src+(r.src.includes("?")?"&":"?")+"full=1",document.head.appendChild(m)}catch{}return}h=location.href,u=e.api||"/api/public/v1/events";try{p=localStorage.supermetry_ignore}catch{}d=location.href,i=0,t=document.hidden?0:Date.now(),s=0,n=!0;function y(){return!p&&!navigator.globalPrivacyControl&&navigator.doNotTrack!="1"&&window.doNotTrack!="1"}function w(e){if(!y())return;e=JSON.stringify(e),navigator.sendBeacon?.(u,e)||fetch(u,{method:"POST",body:e,keepalive:!0}).catch(()=>0)}function v(t){return e.hash=="true"?t:t.split("#")[0]}function o(t,n,s,o,i,a){var r={v:1,ts:Date.now(),id:(crypto.randomUUID?.()||Math.random().toString(16)).replace(/\W/g,""),site:e.site,name:t,url:d,referrer:document.referrer,screen_width:screen.width,props:n||{},engagement_ms:s||0,scroll_pct:o||0,traits:a};i&&(r.revenue=i),c&&(r.profile_id=c),w(r)}function l(){t&&(i+=Date.now()-t,t=0),g(),n&&(o("engagement",{},Math.round(i),s),i=s=0,n=!1)}function g(){var e=Math.max(document.documentElement.scrollHeight,document.body?.scrollHeight||0);e&&(s=Math.max(s,Math.min(100,Math.round(100*(scrollY+innerHeight)/e))))}function _(e,a){h=location.href,d=e||location.href,i=s=0,t=document.hidden?0:Date.now(),n=!0,o("pageview",a)}function b(){if(v(location.href)==v(h))return;l(),_()}function j(e){var t=history[e];history[e]=(...e)=>{t.apply(history,e),b()}}j("pushState"),addEventListener("popstate",b),j("replaceState"),addEventListener("scroll",()=>{g(),n=!0},{passive:!0}),document.addEventListener("visibilitychange",()=>document.hidden?l():(t=Date.now(),n=!0)),addEventListener("pagehide",l),a=window.supermetry=window.supermetry||{},f=e=>()=>{p=e;try{e?localStorage.supermetry_ignore=1:delete localStorage.supermetry_ignore}catch{}},a.optOut=f(1),a.optIn=f(0),a.track=(e,t,n)=>o(e,t,0,0,n?.revenue),a.identify=(t,n={})=>{if(e.profiles=="false"||!y())return;if(t===null)return c=0;if(t!==String(t)||!t||t.length>128||!n||typeof n!="object"||Array.isArray(n))return;var s,i=0;for(s in n)if(++i>20||!s.length||s.length>64||typeof n[s]!="string"||n[s].length>256)return;c=t,o("identify",0,0,0,0,n)},o("pageview")})() \ No newline at end of file +(()=>{var t,n,s,a,r,c,l,d,u,h,m,y,i=document.currentScript,e=i.dataset;if(!e.site||navigator.webdriver||!i.hasAttribute("data-allow-localhost")&&/(^|\.)localhost$|^127\.|^\[::1\]$/.test(location.hostname))return;if(e.consent=="required"||e.manual=="true"||e.beforeSend){try{h=i.cloneNode(),h.src=i.src+(i.src.includes("?")?"&":"?")+"full=1",document.head.appendChild(h)}catch{}return}u=location.href,y=e.api||"/api/public/v1/events";try{m=localStorage.supermetry_ignore}catch{}l=location.href,a=0,t=document.hidden?0:Date.now(),s=0,n=!0;function j(){return!m&&!navigator.globalPrivacyControl&&navigator.doNotTrack!="1"&&window.doNotTrack!="1"}function _(e){if(!j())return;e=JSON.stringify(e),fetch(y,{method:"POST",body:e,keepalive:!0}).catch(()=>0)}function g(t){return e.hash=="true"?t:t.split("#")[0]}function o(t,n,s,o,i,a){var r={v:1,ts:Date.now(),id:(crypto.randomUUID?.()||Math.random().toString(16)).replace(/\W/g,""),site:e.site,name:t,url:l,referrer:document.referrer,screen_width:screen.width,props:n||{},engagement_ms:s||0,scroll_pct:o||0,traits:a};i&&(r.revenue=i),c&&(r.profile_id=c),_(r)}function f(){t&&(a+=Date.now()-t,t=0),p(),n&&(o("engagement",{},Math.round(a),s),a=s=0,n=!1)}function p(){var e=Math.max(document.documentElement.scrollHeight,document.body?.scrollHeight||0);e&&(s=Math.max(s,Math.min(100,Math.round(100*(scrollY+innerHeight)/e))))}function w(e,i){u=location.href,l=e||location.href,a=s=0,t=document.hidden?0:Date.now(),n=!0,o("pageview",i)}function v(){if(g(location.href)==g(u))return;f(),w()}function b(e){var t=history[e];history[e]=(...e)=>{t.apply(history,e),v()}}b("pushState"),addEventListener("popstate",v),b("replaceState"),addEventListener("scroll",()=>{p(),n=!0},{passive:!0}),document.addEventListener("visibilitychange",()=>document.hidden?f():(t=Date.now(),n=!0)),addEventListener("pagehide",f),r=window.supermetry=window.supermetry||{},d=e=>()=>{m=e;try{e?localStorage.supermetry_ignore=1:delete localStorage.supermetry_ignore}catch{}},r.optOut=d(1),r.optIn=d(0),r.track=(e,t,n)=>o(e,t,0,0,n?.revenue),r.identify=(t,n={})=>{if(e.profiles=="false"||!j())return;if(t===null)return c=0;if(t!==String(t)||!t||t.length>128||!n||typeof n!="object"||Array.isArray(n))return;var s,i=0;for(s in n)if(++i>20||!s.length||s.length>64||typeof n[s]!="string"||n[s].length>256)return;c=t,o("identify",0,0,0,0,n)},o("pageview")})() \ No newline at end of file diff --git a/internal/tracker/tracker.runtime.min.js b/internal/tracker/tracker.runtime.min.js index 529dba56..33ecbab2 100644 --- a/internal/tracker/tracker.runtime.min.js +++ b/internal/tracker/tracker.runtime.min.js @@ -1 +1 @@ -(()=>{var e,t,s,o,i,a,r,c,l,u,m,f,p,g,v,_,C=document.currentScript,n=C.dataset;if(!n.site||navigator.webdriver||!C.hasAttribute("data-allow-localhost")&&/(^|\.)localhost$|^127\.|^\[::1\]$/.test(location.hostname))return;o=n.manual=="true",c=!o,g=location.href,f=n.api||"/api/public/v1/events";try{_=localStorage.supermetry_ignore}catch{}m=location.href,l=0,s=o?0:document.hidden?0:Date.now(),r=0,a=!o&&!0;function b(){return!_&&!navigator.globalPrivacyControl&&navigator.doNotTrack!="1"&&window.doNotTrack!="1"&&!u&&!t}function y(e){if(!b())return;e=JSON.stringify(e),navigator.sendBeacon?.(f,e)||fetch(f,{method:"POST",body:e,keepalive:!0}).catch(()=>0)}function k(e){return n.hash=="true"?e:e.split("#")[0]}function A(t){var s,o=e.beforeSend||window[n.beforeSend];if(typeof o!="function")return t;try{s=o({name:t.name,url:t.url,referrer:t.referrer,props:t.props,engagement_ms:t.engagement_ms,scroll_pct:t.scroll_pct})}catch{return}if(!s||typeof s!="object")return;return["name","url","referrer","props","engagement_ms","scroll_pct"].forEach(e=>{e in s&&(t[e]=s[e])}),t}v=n.consent=="required",t=v?[]:0;function w(e){if(u)return;if(t){if(t.length==20){var n=t.findIndex(e=>e.name!="pageview");t.splice(n<0?0:n,1)}t.push(e);return}e=A(e),e&&y(e)}function d(e,t,s,o,a,r){var c={v:1,ts:Date.now(),id:(crypto.randomUUID?.()||Math.random().toString(16)).replace(/\W/g,""),site:n.site,name:e,url:m,referrer:document.referrer,screen_width:screen.width,props:t||{},engagement_ms:s||0,scroll_pct:o||0,traits:r};a&&(c.revenue=a),i&&(c.profile_id=i),w(c);return;y(c)}function h(){if(!c)return;s&&(l+=Date.now()-s,s=0),O(),a&&(d("engagement",{},Math.round(l),r),l=r=0,a=!1)}function O(){var e=Math.max(document.documentElement.scrollHeight,document.body?.scrollHeight||0);e&&(r=Math.max(r,Math.min(100,Math.round(100*(scrollY+innerHeight)/e))))}function x(e,t){c=!0,g=location.href,m=e||location.href,l=r=0,s=document.hidden?0:Date.now(),a=!0,d("pageview",t)}function j(){if(o||k(location.href)==k(g))return;e._nav.forEach(e=>e()),h(),x()}function E(e){var t=history[e];history[e]=(...e)=>{t.apply(history,e),j()}}E("pushState"),addEventListener("popstate",j),n.hash=="true"&&addEventListener("hashchange",j),E("replaceState"),addEventListener("scroll",()=>{if(!c)return;O(),a=!0},{passive:!0}),document.addEventListener("visibilitychange",()=>c?document.hidden?h():(s=Date.now(),a=!0):0),addEventListener("pagehide",h),e=window.supermetry=window.supermetry||{},e._nav=[],e._post=y,e._allowed=b,p=n=>()=>{_=n,n&&(i=0,t&&(t=[]),e._clearHeatmap?.());try{n?localStorage.supermetry_ignore=1:delete localStorage.supermetry_ignore}catch{}e._privacyState=!n,e._privacy?.(!n)},e.optOut=p(1),e.optIn=p(0),e.pageview=e=>{var t=e?.url;try{t=t?new URL(t,location.href).href:location.href}catch{return}h(),x(t,e?.props)},e.consent=n=>{if(u=!n,n||(i=0,e._clearHeatmap?.()),e._privacyState=!!n,e._privacy?.(!!n),!v)return;var s=t;u=!n,t=n?0:[],n&&s&&s.forEach(w)},e.track=(e,t,n)=>d(e,t,0,0,n?.revenue),e.identify=(e,t={})=>{if(n.profiles=="false"||!b())return;if(e===null)return i=0;if(e!==String(e)||!e||e.length>128||!t||typeof t!="object"||Array.isArray(t))return;var s,o=0;for(s in t)if(++o>20||!s.length||s.length>64||typeof t[s]!="string"||t[s].length>256)return;i=e,d("identify",0,0,0,0,t)},o||d("pageview")})() \ No newline at end of file +(()=>{var e,t,s,o,i,a,r,c,l,u,m,f,p,g,v,w,C=document.currentScript,n=C.dataset;if(!n.site||navigator.webdriver||!C.hasAttribute("data-allow-localhost")&&/(^|\.)localhost$|^127\.|^\[::1\]$/.test(location.hostname))return;o=n.manual=="true",c=!o,g=location.href,w=n.api||"/api/public/v1/events";try{f=localStorage.supermetry_ignore}catch{}m=location.href,l=0,s=o?0:document.hidden?0:Date.now(),r=0,a=!o&&!0;function b(){return!f&&!navigator.globalPrivacyControl&&navigator.doNotTrack!="1"&&window.doNotTrack!="1"&&!u&&!t}function y(e){if(!b())return;e=JSON.stringify(e),fetch(w,{method:"POST",body:e,keepalive:!0}).catch(()=>0)}function k(e){return n.hash=="true"?e:e.split("#")[0]}function A(t){var s,o=e.beforeSend||window[n.beforeSend];if(typeof o!="function")return t;try{s=o({name:t.name,url:t.url,referrer:t.referrer,props:t.props,engagement_ms:t.engagement_ms,scroll_pct:t.scroll_pct})}catch{return}if(!s||typeof s!="object")return;return["name","url","referrer","props","engagement_ms","scroll_pct"].forEach(e=>{e in s&&(t[e]=s[e])}),t}v=n.consent=="required",t=v?[]:0;function _(e){if(u)return;if(t){if(t.length==20){var n=t.findIndex(e=>e.name!="pageview");t.splice(n<0?0:n,1)}t.push(e);return}e=A(e),e&&y(e)}function d(e,t,s,o,a,r){var c={v:1,ts:Date.now(),id:(crypto.randomUUID?.()||Math.random().toString(16)).replace(/\W/g,""),site:n.site,name:e,url:m,referrer:document.referrer,screen_width:screen.width,props:t||{},engagement_ms:s||0,scroll_pct:o||0,traits:r};a&&(c.revenue=a),i&&(c.profile_id=i),_(c);return;y(c)}function h(){if(!c)return;s&&(l+=Date.now()-s,s=0),O(),a&&(d("engagement",{},Math.round(l),r),l=r=0,a=!1)}function O(){var e=Math.max(document.documentElement.scrollHeight,document.body?.scrollHeight||0);e&&(r=Math.max(r,Math.min(100,Math.round(100*(scrollY+innerHeight)/e))))}function x(e,t){c=!0,g=location.href,m=e||location.href,l=r=0,s=document.hidden?0:Date.now(),a=!0,d("pageview",t)}function j(){if(o||k(location.href)==k(g))return;e._nav.forEach(e=>e()),h(),x()}function E(e){var t=history[e];history[e]=(...e)=>{t.apply(history,e),j()}}E("pushState"),addEventListener("popstate",j),n.hash=="true"&&addEventListener("hashchange",j),E("replaceState"),addEventListener("scroll",()=>{if(!c)return;O(),a=!0},{passive:!0}),document.addEventListener("visibilitychange",()=>c?document.hidden?h():(s=Date.now(),a=!0):0),addEventListener("pagehide",h),e=window.supermetry=window.supermetry||{},e._nav=[],e._post=y,e._allowed=b,p=n=>()=>{f=n,n&&(i=0,t&&(t=[]),e._clearHeatmap?.());try{n?localStorage.supermetry_ignore=1:delete localStorage.supermetry_ignore}catch{}e._privacyState=!n,e._privacy?.(!n)},e.optOut=p(1),e.optIn=p(0),e.pageview=e=>{var t=e?.url;try{t=t?new URL(t,location.href).href:location.href}catch{return}h(),x(t,e?.props)},e.consent=n=>{if(u=!n,n||(i=0,e._clearHeatmap?.()),e._privacyState=!!n,e._privacy?.(!!n),!v)return;var s=t;u=!n,t=n?0:[],n&&s&&s.forEach(_)},e.track=(e,t,n)=>d(e,t,0,0,n?.revenue),e.identify=(e,t={})=>{if(n.profiles=="false"||!b())return;if(e===null)return i=0;if(e!==String(e)||!e||e.length>128||!t||typeof t!="object"||Array.isArray(t))return;var s,o=0;for(s in t)if(++o>20||!s.length||s.length>64||typeof t[s]!="string"||t[s].length>256)return;i=e,d("identify",0,0,0,0,t)},o||d("pageview")})() \ No newline at end of file diff --git a/internal/tracker/tracker_runtime_test.js b/internal/tracker/tracker_runtime_test.js index ac855ec7..16082bf8 100644 --- a/internal/tracker/tracker_runtime_test.js +++ b/internal/tracker/tracker_runtime_test.js @@ -151,8 +151,14 @@ function loadTracker({full = false, dataset = {}, withExtensions = false, prelud events.set(key, (...args) => registered.forEach((callback) => callback(...args))); }, document, - fetch() { - throw new Error("sendBeacon unexpectedly fell back to fetch"); + // Events go out with fetch(keepalive), never sendBeacon: blockers file + // beacons under the "ping" type, which filter lists block wholesale. + fetch(_endpoint, options) { + if (!options || options.method !== "POST" || options.keepalive !== true || typeof options.body !== "string") { + throw new Error(`unexpected fetch options ${JSON.stringify(options)}`); + } + payloads.push(JSON.parse(options.body)); + return Promise.resolve({ok: true}); }, history, innerHeight: 500, @@ -163,9 +169,8 @@ function loadTracker({full = false, dataset = {}, withExtensions = false, prelud globalPrivacyControl, doNotTrack, webdriver: false, - sendBeacon(_endpoint, body) { - payloads.push(JSON.parse(body)); - return true; + sendBeacon() { + throw new Error("tracker used sendBeacon"); }, }, screen: {width: 1440}, diff --git a/internal/tracker/tracker_test.go b/internal/tracker/tracker_test.go index b14bf27e..29b493ab 100644 --- a/internal/tracker/tracker_test.go +++ b/internal/tracker/tracker_test.go @@ -92,7 +92,6 @@ func TestScriptContract(t *testing.T) { `/api/public/v1/events`, `data-allow-localhost`, `navigator.webdriver`, - `navigator.sendBeacon`, `keepalive: true`, `route("pushState")`, `route("replaceState")`, From a844a491cd418d21ffca84952b92bf7696e73309 Mon Sep 17 00:00:00 2001 From: velixio <270455167+velixio@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:11:57 +0530 Subject: [PATCH 2/4] settings: ad blocker section with first-party proxy recipes Settings -> Installation explains what the tracker already does and offers nginx, Caddy, Cloudflare Worker and Next.js proxy configurations filled in with the site's ID and host, the first-party snippet, and server-side events. On managed instances it links the site's proxy key and sends the key headers; self-hosted, it explains SUPERMETRY_TRUSTED_PROXIES. --- .../features/settings/InstallRecipes.test.tsx | 34 +++++- web/src/features/settings/InstallRecipes.tsx | 62 +++++++++- web/src/features/settings/SettingsPages.tsx | 2 + web/src/features/settings/SiteSettings.tsx | 11 +- .../features/settings/installRecipes.test.ts | 49 +++++++- web/src/features/settings/installRecipes.ts | 112 +++++++++++++++++- .../features/settings/site-settings.test.tsx | 3 +- web/src/lib/tracker-full.test.ts | 23 ++-- 8 files changed, 272 insertions(+), 24 deletions(-) diff --git a/web/src/features/settings/InstallRecipes.test.tsx b/web/src/features/settings/InstallRecipes.test.tsx index 86678fae..37ef4674 100644 --- a/web/src/features/settings/InstallRecipes.test.tsx +++ b/web/src/features/settings/InstallRecipes.test.tsx @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' const copyText = vi.fn((_value: string) => Promise.resolve(true)) vi.mock('@/lib/clipboard', () => ({ copyText: (value: string) => copyText(value) })) -import { AiInstallPrompt, InstallRecipes, SiteDetails, VerifyInstallation } from './InstallRecipes' +import { AdBlockerSetup, AiInstallPrompt, InstallRecipes, SiteDetails, VerifyInstallation } from './InstallRecipes' const snippet = '' @@ -83,3 +83,35 @@ describe('AiInstallPrompt', () => { expect(copyText.mock.calls[0]?.[0]).toContain('Host: https://stats.supermetry.com') }) }) + +describe('AdBlockerSetup', () => { + afterEach(() => { cleanup(); copyText.mockClear() }) + const cloudSnippet = '' + + it('explains the options and offers proxy recipes with the site filled in', () => { + render() + expect(screen.getByRole('heading', { name: 'Ad blockers' })).toBeVisible() + expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['nginx', 'Caddy', 'Cloudflare Worker', 'Next.js proxy']) + expect(screen.getByLabelText('nginx snippet')).toHaveTextContent('proxy_pass https://app.supermetry.com') + expect(screen.getByLabelText('First-party snippet')).toHaveTextContent('') + expect(screen.getByText(/From your server/)).toBeVisible() + }) + + it('links the Cloud proxy key page on managed instances', () => { + render() + expect(screen.getByRole('link', { name: 'Create the proxy key' })).toHaveAttribute('href', '/cloud/sites/abc123def456/proxy') + }) + + it('asks self-hosters to trust the proxy instead of a key', () => { + render() + expect(screen.queryByRole('link', { name: 'Create the proxy key' })).toBeNull() + expect(screen.getAllByText(/SUPERMETRY_TRUSTED_PROXIES/).length).toBeGreaterThan(0) + expect(screen.getByLabelText('nginx snippet')).not.toHaveTextContent('Proxy-Key') + }) + + it('copies the first-party snippet', async () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Copy first-party snippet' })) + await waitFor(() => expect(copyText).toHaveBeenCalledWith('')) + }) +}) diff --git a/web/src/features/settings/InstallRecipes.tsx b/web/src/features/settings/InstallRecipes.tsx index 64d5cb28..efcf55fd 100644 --- a/web/src/features/settings/InstallRecipes.tsx +++ b/web/src/features/settings/InstallRecipes.tsx @@ -4,7 +4,7 @@ import { CheckIcon, CopyIcon, SparklesIcon } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { copyText } from '@/lib/clipboard' -import { aiInstallPrompt, buildRecipes, parseSnippet, trackerHost, type Recipe } from './installRecipes' +import { aiInstallPrompt, buildProxyRecipes, buildRecipes, firstPartySnippet, parseSnippet, trackerHost, type Recipe } from './installRecipes' export type VerifyResult = { status?: 'waiting' | 'receiving' | 'quiet'; lastSeenAt?: string | null } @@ -178,3 +178,63 @@ export function InstallRecipes({ publicId, snippet, verify, troubleshooting }: { ) } + +/** + * How to count visitors whose ad blocker blocks the Supermetry domain: a + * first-party proxy on the customer's own domain (with the Cloud proxy key on + * managed instances), or server-side events. + */ +export function AdBlockerSetup({ publicId, snippet, managed }: { publicId: string; snippet?: string; managed: boolean }) { + const input = useRecipeInput(publicId, snippet) + const recipes = useMemo(() => buildProxyRecipes(input, { managed }), [input, managed]) + const tag = firstPartySnippet(input) + let step = 0 + return ( +
+

Ad blockers

+

+ The tracker already sends events to neutral URLs for this site, which most blocker rules don't match. Some blockers block the Supermetry domain itself. + To count those visitors too, serve tracking from your own domain. +

+ +

Option 1: Proxy through your own domain (recommended)

+

+ Your server forwards the tracking requests under /sm/ to {trackerHost(input.scriptUrl)}. Blockers then only see your own domain. +

+
    + {managed ? ( +
  1. +

    {++step}. Create the site's proxy key

    +

    + With the key, Supermetry Cloud counts each visitor's own location instead of your server's. Keep it on the server only. +

    +

    Create the proxy key

    +
  2. + ) : null} +
  3. +

    {++step}. Add the proxy to your server

    + + + {recipes.map((recipe) => {recipe.label})} + + {recipes.map((recipe) => ( + + ))} + +
  4. +
  5. +

    {++step}. Replace the snippet on your pages

    +

    Load the script from your own domain. With the Next.js proxy, render the component with host="" instead.

    +
    {tag}
    + +
  6. +
+ +

Option 2: Send events from your server

+

+ Events your backend sends can't be blocked, but they miss what only the browser knows: screen size, engagement time, scroll depth and session replay. + Use them alongside the script. See From your server in the install recipes above. +

+
+ ) +} diff --git a/web/src/features/settings/SettingsPages.tsx b/web/src/features/settings/SettingsPages.tsx index 7ce78b5a..aa3ca801 100644 --- a/web/src/features/settings/SettingsPages.tsx +++ b/web/src/features/settings/SettingsPages.tsx @@ -426,6 +426,7 @@ export function InstallationPage({ refetchInterval: (query) => installationStatusInterval(query.state.data?.install_status), enabled: !isDemo, }); + const config = useInstanceConfig(); const snippet = useQuery({ queryKey: ["site-snippet", orgId, siteId], queryFn: () => @@ -455,6 +456,7 @@ export function InstallationPage({ snippetLoading={snippet.isPending} snippetFailed={snippet.isError} onRetrySnippet={() => void snippet.refetch()} + managed={config.data?.managed === true} verify={async () => { const result = await status.refetch(); return { status: result.data?.install_status, lastSeenAt: result.data?.last_seen_at }; diff --git a/web/src/features/settings/SiteSettings.tsx b/web/src/features/settings/SiteSettings.tsx index e26e00f7..32775fa4 100644 --- a/web/src/features/settings/SiteSettings.tsx +++ b/web/src/features/settings/SiteSettings.tsx @@ -7,7 +7,7 @@ import type { Role } from '@/components/Sidebar' import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { refetchSiteIcon } from '@/features/sites/api' -import { AiInstallPrompt, InstallRecipes, SiteDetails, type VerifyResult } from './InstallRecipes' +import { AdBlockerSetup, AiInstallPrompt, InstallRecipes, SiteDetails, type VerifyResult } from './InstallRecipes' import { TrackingSnippet } from './TrackingSnippet' import { InstallStatusCard, InstallTroubleshooting, type InstallStatus } from '@/features/sites/InstallStatus' import { uiFlows } from '@/messages/uiFlows' @@ -169,8 +169,10 @@ function FeatureSetup({ currency }: { currency?: string }) { ) } -export function InstallGuide({ publicId, snippet, fullSnippet, installStatus, lastSeenAt, revenueCurrency, verify, now = new Date(), dashboardHref, domainsHref, snippetLoading = false, snippetFailed = false, onRetrySnippet }: { +export function InstallGuide({ publicId, snippet, fullSnippet, installStatus, lastSeenAt, revenueCurrency, verify, now = new Date(), dashboardHref, domainsHref, snippetLoading = false, snippetFailed = false, onRetrySnippet, managed = false }: { publicId: string; snippet?: string; fullSnippet?: string; installStatus?: InstallStatus; lastSeenAt?: string | null; revenueCurrency?: string + /** Managed (Cloud) instance: the proxy recipes use the site's proxy key. */ + managed?: boolean verify?: () => Promise; now?: Date /** In-app links for the status card; omitted outside the router. */ dashboardHref?: string; domainsHref?: string @@ -197,10 +199,7 @@ export function InstallGuide({ publicId, snippet, fullSnippet, installStatus, la

Add an event name and optional properties to any button, link, or form.

{''}
- + ) } diff --git a/web/src/features/settings/installRecipes.test.ts b/web/src/features/settings/installRecipes.test.ts index 4d0923ff..88f0cec3 100644 --- a/web/src/features/settings/installRecipes.test.ts +++ b/web/src/features/settings/installRecipes.test.ts @@ -1,7 +1,7 @@ import ts from 'typescript' import { describe, expect, it } from 'vitest' -import { aiInstallPrompt, buildRecipes, eventsUrl, parseSnippet, trackerHost, type Recipe } from './installRecipes' +import { aiInstallPrompt, buildProxyRecipes, buildRecipes, eventsUrl, firstPartySnippet, parseSnippet, trackerHost, type Recipe } from './installRecipes' const input = { publicId: 'abc123def456', scriptUrl: 'https://stats.supermetry.com/js/script.js' } const recipes = buildRecipes(input) @@ -104,3 +104,50 @@ describe('install recipes', () => { expect(eventsUrl('https://a.example:8443/js/script.js')).toBe('https://a.example:8443/api/public/v1/events') }) }) + +describe('first-party proxy recipes', () => { + const cloud = { publicId: 'abc123def456', scriptUrl: 'https://app.supermetry.com/js/abc123def456.js' } + const proxyById = (recipes: Recipe[], id: string): Recipe => { + const found = recipes.find((recipe) => recipe.id === id) + if (!found) throw new Error(`missing proxy recipe ${id}`) + return found + } + + it('offers nginx, Caddy, a Cloudflare Worker and Next.js', () => { + expect(buildProxyRecipes(cloud, { managed: true }).map((recipe) => recipe.id)).toEqual(['proxy-nginx', 'proxy-caddy', 'proxy-cloudflare', 'proxy-nextjs']) + }) + + it('forwards every tracking path to the real host', () => { + for (const recipe of buildProxyRecipes(cloud, { managed: true })) { + expect(recipe.code, recipe.id).toContain('app.supermetry.com') + if (recipe.id === 'proxy-nextjs') continue + for (const path of ['js/', 'px/', 'api/public/', 'replay', 'api/replay/']) expect(recipe.code, `${recipe.id} ${path}`).toContain(path) + } + }) + + it('sends the Cloud proxy key and visitor address only on managed instances', () => { + for (const recipe of buildProxyRecipes(cloud, { managed: true })) { + expect(recipe.code, recipe.id).toMatch(/X-Supermetry-Proxy-Key|proxyKey/) + } + for (const recipe of buildProxyRecipes({ ...cloud, scriptUrl: 'https://stats.example.org/js/abc123def456.js' }, { managed: false })) { + expect(recipe.code, recipe.id).not.toMatch(/Proxy-Key|proxyKey|smpk_/) + expect(recipe.note ?? '', recipe.id).toContain('SUPERMETRY_TRUSTED_PROXIES') + } + }) + + it('never puts a real-looking key in the snippet', () => { + for (const recipe of buildProxyRecipes(cloud, { managed: true })) { + expect(recipe.code, recipe.id).not.toMatch(/smpk_[A-Za-z0-9]{8,}/) + } + }) + + it('gives JavaScript recipes valid syntax', () => { + const recipes = buildProxyRecipes(cloud, { managed: true }) + expect(syntaxErrors(proxyById(recipes, 'proxy-cloudflare').code, ts.ScriptKind.JS)).toEqual([]) + expect(syntaxErrors(proxyById(recipes, 'proxy-nextjs').code, ts.ScriptKind.TS)).toEqual([]) + }) + + it('points the first-party snippet at the proxy prefix', () => { + expect(firstPartySnippet(cloud)).toBe('') + }) +}) diff --git a/web/src/features/settings/installRecipes.ts b/web/src/features/settings/installRecipes.ts index ed91dbb4..dcaef4f5 100644 --- a/web/src/features/settings/installRecipes.ts +++ b/web/src/features/settings/installRecipes.ts @@ -8,12 +8,12 @@ export type RecipeInput = { scriptUrl: string } -export type RecipeLanguage = 'html' | 'tsx' | 'ts' | 'js' | 'php' | 'liquid' | 'sh' | 'python' | 'go' +export type RecipeLanguage = 'html' | 'tsx' | 'ts' | 'js' | 'php' | 'liquid' | 'sh' | 'python' | 'go' | 'nginx' | 'caddy' export type Recipe = { id: string label: string - group: 'browser' | 'server' | 'mobile' + group: 'browser' | 'server' | 'mobile' | 'proxy' /** Package install command, for recipes built on an @supermetry package. */ install?: string /** File or place the snippet goes. */ @@ -307,3 +307,111 @@ Host: ${host} When done, tell me which files you changed.` } + +// First-party proxy recipes: the customer's own server forwards the tracking +// paths, under PROXY_PREFIX, to the Supermetry host, so blockers that block +// the Supermetry domain see only the customer's domain. On a managed (Cloud) +// instance each recipe sends the site's proxy key and the visitor's address so +// Cloud counts real visitors; self-hosted instances trust X-Forwarded-For from +// SUPERMETRY_TRUSTED_PROXIES instead. + +export const PROXY_PREFIX = '/sm' + +/** The snippet to use once the proxy is live: the script from the site's own domain. */ +export function firstPartySnippet({ publicId }: RecipeInput): string { + return `` +} + +const trustedProxiesNote = 'Self-hosted: add this server\'s address to SUPERMETRY_TRUSTED_PROXIES on your Supermetry server, so it reads the visitor address from X-Forwarded-For instead of counting every visitor as your proxy.' +const proxyKeyNote = 'Keep the proxy key on the server only (config file, environment or secret store). Never put it in HTML or client code.' + +export function buildProxyRecipes(input: RecipeInput, { managed }: { managed: boolean }): Recipe[] { + const host = trackerHost(input.scriptUrl) + const hostname = new URL(host).host + const p = PROXY_PREFIX + const note = managed ? proxyKeyNote : trustedProxiesNote + + const nginxKey = managed ? ` proxy_set_header X-Supermetry-Proxy-Key $sm_key; + proxy_set_header X-Supermetry-Client-IP $remote_addr; # overwrites any value from the browser +` : '' + const nginx = `${managed ? `# The proxy key stays in this file (or an include only nginx can read). +set $sm_key "smpk_REPLACE_WITH_YOUR_KEY"; + +` : ''}# Forward only the tracking paths, never the whole app. +location ~ ^${p}/(js/|px/|api/public/|replay\\.js$|api/replay/) { + rewrite ^${p}/(.*)$ /$1 break; + proxy_pass ${host}; + proxy_ssl_server_name on; + client_max_body_size 2m; # session replay uploads + proxy_set_header Host ${hostname}; +${nginxKey} proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Cookie ""; # tracking needs no cookies +}` + + const caddyKey = managed ? ` header_up X-Supermetry-Proxy-Key {env.SUPERMETRY_PROXY_KEY} + header_up X-Supermetry-Client-IP {client_ip} +` : '' + const caddy = `your-site.example { + @supermetry path ${p}/js/* ${p}/px/* ${p}/api/public/* ${p}/replay.js ${p}/api/replay/* + handle @supermetry { + uri strip_prefix ${p} + reverse_proxy ${host} { + header_up Host ${hostname} +${caddyKey} header_up X-Forwarded-For {client_ip} + header_up -Cookie + } + } +}` + + const workerKey = managed ? `const visitor = request.headers.get('CF-Connecting-IP') +headers.delete('X-Supermetry-Client-IP') // never pass on what the browser sent +if (visitor) { + headers.set('X-Supermetry-Proxy-Key', env.SUPERMETRY_PROXY_KEY) + headers.set('X-Supermetry-Client-IP', visitor) +}` : `const visitor = request.headers.get('CF-Connecting-IP') +if (visitor) headers.set('X-Forwarded-For', visitor)` + const worker = `// Route: your-site.example${p}/*${managed ? ` +// Secret: wrangler secret put SUPERMETRY_PROXY_KEY` : ''} +const prefixes = [${['js/', 'px/', 'api/public/', 'api/replay/'].map((path) => q(`${p}/${path}`)).join(', ')}] +const files = [${q(`${p}/replay.js`)}] + +export default { + async fetch(request, env) { + const url = new URL(request.url) + const path = url.pathname + if (!files.includes(path) && !prefixes.some((prefix) => path.startsWith(prefix))) { + return new Response('Not found', { status: 404 }) + } + const target = new URL(path.slice(${p.length}) + url.search, ${q(host)}) + const headers = new Headers(request.headers) + headers.delete('Cookie') +${workerKey.replace(/^/gm, ' ')} + return fetch(target, { method: request.method, headers, body: request.body, redirect: 'manual' }) + }, +}` + + const nextjs = `// lib/supermetry.ts +import { createSupermetryProxy } from '@supermetry/nextjs/proxy' + +export const proxy = createSupermetryProxy({ + host: ${q(host)},${managed ? ` + proxyKey: process.env.SUPERMETRY_PROXY_KEY, + // The visitor address from a header your platform sets and browsers cannot + // (x-real-ip on Vercel; adjust for your host). + getClientIp: (request) => request.headers.get('x-real-ip'),` : ''} +}) + +// Then add one route file per path, each re-exporting the proxy: +// app/js/[file]/route.ts export const { GET, HEAD } = proxy +// app/api/public/v1/events/route.ts export const { POST } = proxy +// app/replay.js/route.ts export const { GET, HEAD } = proxy (session replay) +// app/api/replay/[...path]/route.ts export const { POST, PUT } = proxy (session replay) +// and render so the script loads from your origin.` + + return [ + { id: 'proxy-nginx', label: 'nginx', group: 'proxy', where: 'Your site\'s server block', language: 'nginx', note, code: nginx }, + { id: 'proxy-caddy', label: 'Caddy', group: 'proxy', where: 'Your Caddyfile', language: 'caddy', note: managed ? `${proxyKeyNote} Set SUPERMETRY_PROXY_KEY in Caddy's environment.` : note, code: caddy }, + { id: 'proxy-cloudflare', label: 'Cloudflare Worker', group: 'proxy', where: `A Worker on the route your-site.example${p}/*`, language: 'js', note, code: worker }, + { id: 'proxy-nextjs', label: 'Next.js proxy', group: 'proxy', install: 'npm install @supermetry/nextjs', where: 'lib/supermetry.ts and four route files', language: 'ts', note, code: nextjs }, + ] +} diff --git a/web/src/features/settings/site-settings.test.tsx b/web/src/features/settings/site-settings.test.tsx index d7cd1afc..57c0caca 100644 --- a/web/src/features/settings/site-settings.test.tsx +++ b/web/src/features/settings/site-settings.test.tsx @@ -79,7 +79,8 @@ describe('site settings', () => { expect(screen.getByText('Receiving')).toBeVisible() expect(screen.getByText('Last event 20 seconds ago')).toBeVisible() - expect(screen.getByText(/proxy the script through your own domain/i)).toBeVisible() + expect(screen.getByRole('heading', { name: 'Ad blockers' })).toBeVisible() + expect(screen.getByRole('heading', { name: /Proxy through your own domain/ })).toBeVisible() expect(screen.getByText('Track custom clicks')).toBeVisible() expect(screen.getByText(/data-supermetry-event="Signup"/)).toBeVisible() const setup = screen.getByRole('region', { name: 'Feature setup' }) diff --git a/web/src/lib/tracker-full.test.ts b/web/src/lib/tracker-full.test.ts index 21501691..3dfead70 100644 --- a/web/src/lib/tracker-full.test.ts +++ b/web/src/lib/tracker-full.test.ts @@ -39,11 +39,18 @@ function load(attributes = '', body = '', withObservers = false, navigationRespo } } Object.defineProperty(dom.window, 'Blob', { configurable: true, value: BodyBlob }) + // Events go out with fetch(keepalive); sendBeacon is a "ping" request that + // filter lists block wholesale, so the tracker must not use it. Object.defineProperty(dom.window.navigator, 'sendBeacon', { configurable: true, - value: (_url: string, data: BodyBlob | string) => { - payloads.push(JSON.parse(typeof data === 'string' ? data : data.body) as EventPayload) - return true + value: () => { throw new Error('tracker used sendBeacon') }, + }) + Object.defineProperty(dom.window, 'fetch', { + configurable: true, + value: (_url: string, init: { method?: string; body: string; keepalive?: boolean }) => { + if (init.method !== 'POST' || init.keepalive !== true) throw new Error(`unexpected fetch ${JSON.stringify(init)}`) + payloads.push(JSON.parse(init.body) as EventPayload) + return Promise.resolve({}) }, }) const observerHarness: ObserverHarness = { emit: () => {}, queue: () => {}, observed: [] } @@ -160,16 +167,8 @@ describe('full tracker', () => { expect(event(payloads, 'heatmap')).toMatchObject({ doc_h: 100000, vw: 10000 }) }) - it('uses the core transport fallback when sendBeacon is unavailable', () => { + it('sends heatmap clicks with fetch keepalive on pagehide', () => { const { dom, payloads } = load('data-heatmap="true"') - Object.defineProperty(dom.window.navigator, 'sendBeacon', { configurable: true, value: undefined }) - Object.defineProperty(dom.window, 'fetch', { - configurable: true, - value: (_url: string, init: { body: string }) => { - payloads.push(JSON.parse(init.body) as EventPayload) - return Promise.resolve({}) - }, - }) dom.window.document.body.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })) dom.window.dispatchEvent(new dom.window.Event('pagehide')) From f7999a58961cf423a8da9078ce601d5b59174b94 Mon Sep 17 00:00:00 2001 From: velixio <270455167+velixio@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:11:57 +0530 Subject: [PATCH 3/4] test(e2e): fix the install-recipes spec Post the verification event with the site's public ID (not its slug) and match recipe tabs exactly now that a "Next.js proxy" tab exists. --- web/e2e/install-recipes.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/e2e/install-recipes.spec.ts b/web/e2e/install-recipes.spec.ts index 9bb82f1f..8442772f 100644 --- a/web/e2e/install-recipes.spec.ts +++ b/web/e2e/install-recipes.spec.ts @@ -28,12 +28,12 @@ test('install recipes show real snippets, switch platforms and verify the instal await expect(html).toContainText(`data-site="${owner.sitePublicID}"`) await expect(html).toContainText('/js/') - await page.getByRole('tab', { name: 'Next.js' }).click() + await page.getByRole('tab', { name: 'Next.js', exact: true }).click() await expect(page.getByLabel('Next.js install command')).toHaveText('npm install @supermetry/nextjs') await expect(page.getByLabel('Next.js snippet')).toContainText(`site="${owner.sitePublicID}" host="${origin}"`) await page.getByRole('tab', { name: 'Go', exact: true }).click() await expect(page.getByLabel('Go snippet')).toContainText('/api/public/v1/events') - await page.getByRole('tab', { name: 'Google Tag Manager' }).click() + await page.getByRole('tab', { name: 'Google Tag Manager', exact: true }).click() await expect(page.getByLabel('Google Tag Manager snippet')).toContainText('createElement') await noHorizontalOverflow(page) @@ -48,7 +48,7 @@ test('install recipes show real snippets, switch platforms and verify the instal await expect(page.getByText(/Waiting for the first event/)).toBeVisible() const sent = await page.request.post('/api/public/v1/events', { headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) Chrome/126.0 Safari/537.36', Origin: 'https://site1.test' }, - data: { site: owner.siteSlug, name: 'pageview', url: 'https://site1.test/install-check' }, + data: { site: owner.sitePublicID, name: 'pageview', url: 'https://site1.test/install-check' }, }) expect(sent.status()).toBe(202) await expect(page.getByText('Events are arriving. Installation verified.')).toBeVisible({ timeout: 30_000 }) From d7a952f4e6e5f43957a3f22ebdf0fbef77efac0e Mon Sep 17 00:00:00 2001 From: velixio <270455167+velixio@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:11:57 +0530 Subject: [PATCH 4/4] docs: ad blockers guide, README FAQ and changelog --- CHANGELOG.md | 9 ++++ README.md | 7 +++ docs/guide/ad-blockers.md | 94 +++++++++++++++++++++++++++++++++++++ docs/guide/configuration.md | 2 +- 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 docs/guide/ad-blockers.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d5892c43..f53b6dc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Supermetry is a self-hosted web analytics product: one Go binary and PostgreSQL, ## Unreleased +### Changed + +- The tracker sends events with `fetch` (`keepalive`) instead of `navigator.sendBeacon`. Blockers file beacons as "ping" requests, which many filter lists block outright. Events still arrive when the page closes, and there is no extra preflight request. + +### Added + +- An "Ad blockers" section in Settings → Installation. It explains what the tracker already does and has ready-to-copy first-party proxy configurations for nginx, Caddy, a Cloudflare Worker and Next.js, filled in with the site's ID and host. On Supermetry Cloud it links to the site's proxy key and includes the key headers. Self-hosted, it explains `SUPERMETRY_TRUSTED_PROXIES`. It also offers the first-party snippet and points to server-side events. +- A guide, [Ad blockers](docs/guide/ad-blockers.md), linked from the README. + ## v0.1.20 (2026-11-03) ### Added diff --git a/README.md b/README.md index 709eac76..8fd49bcb 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,12 @@ It sets no cookies. IP addresses are used to look up the country (and city, if y That depends on the laws that apply to you and on what else your site runs, so ask your own counsel. Supermetry is designed to need as little consent as possible: no cookies, no raw IPs and no cross-site tracking, and it honours Do Not Track and Global Privacy Control. +
+What about ad blockers? + +The tracker sends events to neutral, per-site URLs with ordinary requests, which most blocker rules don't match. To also count visitors whose blocker blocks the Supermetry domain itself, serve tracking from your own domain through a small proxy (nginx, Caddy, Cloudflare Worker or Next.js; the filled-in configuration is in Settings → Installation), or send events from your server. See [Ad blockers](docs/guide/ad-blockers.md). +
+
Can I move my existing data in? @@ -223,6 +229,7 @@ An EU-hosted Cloud is open: [start free](https://app.supermetry.com/signup?utm_s ## Documentation - [Configuration](docs/guide/configuration.md) +- [Ad blockers](docs/guide/ad-blockers.md): proxy through your own domain, or send events from your server - [Tracking and privacy](docs/tracking-privacy.md) - [API](docs/api.md) and [API authentication](docs/api-auth.md) - [Authorization](docs/authorization.md) diff --git a/docs/guide/ad-blockers.md b/docs/guide/ad-blockers.md new file mode 100644 index 00000000..1b751770 --- /dev/null +++ b/docs/guide/ad-blockers.md @@ -0,0 +1,94 @@ +# Ad blockers + +Some visitors run ad blockers or privacy extensions that stop analytics requests. This guide explains what Supermetry already does about it and what you can add so those visitors are counted too. + +## What Supermetry does by default + +You don't need to change anything for these: + +- **Neutral, per-site URLs.** Your site's script sends events to `/e/` rather than a well-known analytics path such as `/api/.../events`. Session replay uses the same base: `/e//r.js`, `/s`, `/c` and `/k/...`. Most filter rules are written for well-known paths and don't match these. +- **Ordinary requests.** Events go out as normal `fetch` requests (with `keepalive`, so the last event before a page closes still arrives), not as `sendBeacon` "ping" requests, which many filter lists block outright. +- **Short module names.** Optional modules load as `?m=v,h,r`, so the script URL never contains words like `replay` or `heatmap`. + +Some blockers still block requests to the Supermetry domain itself (`app.supermetry.com` on Supermetry Cloud, or your own Supermetry host), whatever the path. To count those visitors, use one of the options below. + +## Check whether you are affected + +1. Open your site in a browser with your ad blocker turned on. +2. Open the developer tools, select the **Network** tab and reload the page. +3. Look for the request to `/e/`. If it shows `blocked`, or the console shows `net::ERR_BLOCKED_BY_CLIENT`, the blocker is stopping it. + +With uBlock Origin you can see the exact rule: open the uBlock menu, then the **Logger** (list icon), and reload your site. Blocked requests are shown in red, with the filter and the list it came from. + +## Option 1: Proxy through your own domain (recommended) + +Your own web server forwards the tracking requests to Supermetry. The browser then only talks to your domain, which blockers have no reason to block. Your pages load the script from `/sm/js/.js` on your own domain. + +The ready-made configuration, already filled in with your site ID and Supermetry host, is in the dashboard: **Settings → Installation → Ad blockers**. It covers nginx, Caddy, a Cloudflare Worker and Next.js. + +### Steps + +1. **Supermetry Cloud only: create the site's proxy key.** In **Settings → Installation → Ad blockers**, select **Create the proxy key**. Without the key, every visitor would appear to come from your server: one location for everyone, and visitors merged together. With the key, Cloud uses each visitor's own address. + + Keep the key on your server only: in the proxy's configuration, environment or secret store. Never put it in HTML, client-side JavaScript or a public repository. + +2. **Add the proxy to your web server.** Forward these paths, with the `/sm` prefix removed, to your Supermetry host: + + | Path on your domain | Forwarded to | + |---|---| + | `/sm/js/...` | `/js/...` (the tracker script) | + | `/sm/px/...` | `/px/...` (tracking pixels) | + | `/sm/api/public/...` | `/api/public/...` (events) | + | `/sm/replay.js` | `/replay.js` (session replay recorder) | + | `/sm/api/replay/...` | `/api/replay/...` (session replay uploads) | + + On every forwarded request, the proxy should also: + + - set `Host` to your Supermetry host; + - on Supermetry Cloud, send `X-Supermetry-Proxy-Key: ` and `X-Supermetry-Client-IP: `. The proxy must always set the address itself, replacing anything the browser sent; + - when self-hosting, send the visitor's address in `X-Forwarded-For`, and add the proxy's address to `SUPERMETRY_TRUSTED_PROXIES` on your Supermetry server (see [Configuration](configuration.md)); + - drop cookies (tracking needs none) and allow request bodies up to 2 MB (session replay uploads). + +3. **Replace the snippet on your pages** with the first-party one: + + ```html + + ``` + + With the Next.js proxy, render `` instead. + +4. **Check it:** reload your site with the ad blocker on. The script and the event requests should now go to your own domain and succeed. + +If a proxy key is wrong, missing or revoked, tracking still works. Visitors are then counted as coming from your server until the key is fixed. + +### nginx example (Supermetry Cloud) + +```nginx +set $sm_key "smpk_REPLACE_WITH_YOUR_KEY"; # keep this file readable only by nginx + +location ~ ^/sm/(js/|px/|api/public/|replay\.js$|api/replay/) { + rewrite ^/sm/(.*)$ /$1 break; + proxy_pass https://app.supermetry.com; + proxy_ssl_server_name on; + client_max_body_size 2m; + proxy_set_header Host app.supermetry.com; + proxy_set_header X-Supermetry-Proxy-Key $sm_key; + proxy_set_header X-Supermetry-Client-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Cookie ""; +} +``` + +When self-hosting, remove the two `X-Supermetry-*` lines and the `set` line, and use your own Supermetry host instead of `app.supermetry.com`. + +If nginx itself sits behind a CDN or load balancer, `$remote_addr` is the CDN's address. Configure nginx's `real_ip` module (`set_real_ip_from` for the CDN's ranges and `real_ip_header` for its client header) so that `$remote_addr` is the visitor's address. + +## Option 2: Send events from your server + +Events your backend sends directly to Supermetry can't be blocked by anything in the browser. They do miss what only the browser knows: screen size, engagement time, scroll depth and session replay. That makes them a good addition to the script, not a replacement. For example, use them to record sign-ups and purchases reliably. + +The dashboard has ready-made code for Node.js, Express, Python, Go and curl under **Settings → Installation → Install recipes → From your server**. See also the [API documentation](../api.md). + +## Turning off the neutral URLs (self-hosted) + +If a proxy or firewall in front of your Supermetry server forwards only specific paths, either forward `/e/` too, or set `SUPERMETRY_NEUTRAL_PATHS=false` so scripts keep the classic paths. See [Configuration](configuration.md#neutral-per-site-paths). diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 94a4fb0b..f627c53a 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -92,7 +92,7 @@ Each site also has its own intake paths under `/e/`. None of the A site's script (`/js/.js`) uses the neutral paths when it is loaded straight from `SUPERMETRY_BASE_URL`. When it is loaded from any other origin, such as a first-party proxy that forwards only the classic paths, it keeps the classic paths, so existing proxies keep working. The script names its lazily loaded modules by short code (`?m=v,h,r`), so its URL never contains `replay` or `heatmap`. The server rejects a request whose body or `?s=` names a site other than the one in the path. The classic paths stay available. -Neutral paths do not help when a filter list blocks the Supermetry host itself. Only a first-party proxy on your own domain avoids that. +Neutral paths do not help when a filter list blocks the Supermetry host itself. A first-party proxy on your own domain, or server-side events, avoids that: see [Ad blockers](ad-blockers.md). ### Referrer spam list