Skip to content

Instantly share code, notes, and snippets.

@idarek
Last active November 14, 2024 08:35
Show Gist options
  • Save idarek/9ade69ac2a2ef00d98ab950426af5791 to your computer and use it in GitHub Desktop.
Save idarek/9ade69ac2a2ef00d98ab950426af5791 to your computer and use it in GitHub Desktop.
/* https://go.tacodewolff.nl/minify */
enScroll=!1,enFdl=!1,extCurrent=void 0,filename=void 0,targetText=void 0,splitOrigin=void 0;const lStor=localStorage,sStor=sessionStorage,doc=document,docEl=document.documentElement,docBody=document.body,docLoc=document.location,w=window,s=screen,nav=navigator||{},extensions=["pdf","xls","xlsx","doc","docx","txt","rtf","csv","exe","key","pps","ppt","pptx","7z","pkg","rar","gz","zip","avi","mov","mp4","mpe","mpeg","wmv","mid","midi","mp3","wav","wma"];function a(e,t,n,o){const j="G-XXXXXXXXXX",r=()=>Math.floor(Math.random()*1e9)+1,c=()=>Math.floor(Date.now()/1e3),F=()=>(sStor._p||(sStor._p=r()),sStor._p),E=()=>r()+"."+c(),_=()=>(lStor.cid_v4||(lStor.cid_v4=E()),lStor.cid_v4),m=lStor.getItem("cid_v4"),v=()=>m?void 0:enScroll==!0?void 0:"1",p=()=>(sStor.sid||(sStor.sid=c()),sStor.sid),O=()=>{if(!sStor._ss)return sStor._ss="1",sStor._ss;if(sStor.getItem("_ss")=="1")return void 0},a="1",g=()=>{if(sStor.sct)if(enScroll==!0)return sStor.sct;else x=+sStor.getItem("sct")+ +a,sStor.sct=x;else sStor.sct=a;return sStor.sct},i=docLoc.search,b=new URLSearchParams(i),h=["q","s","search","query","keyword"],y=h.some(e=>i.includes("&"+e+"=")||i.includes("?"+e+"=")),u=()=>y==!0?"view_search_results":enScroll==!0?"scroll":enFdl==!0?"file_download":"page_view",f=()=>enScroll==!0?"90":void 0,C=()=>{if(u()=="view_search_results"){for(let e of b)if(h.includes(e[0]))return e[1]}else return void 0},d=encodeURIComponent,k=e=>{let t=[];for(let n in e)e.hasOwnProperty(n)&&e[n]!==void 0&&t.push(d(n)+"="+d(e[n]));return t.join("&")},A=!1,S="https://www.google-analytics.com/g/collect",M=k({v:"2",tid:j,_p:F(),sr:(s.width*w.devicePixelRatio+"x"+s.height*w.devicePixelRatio).toString(),ul:(nav.language||void 0).toLowerCase(),cid:_(),_fv:v(),_s:"1",dl:docLoc.origin+docLoc.pathname+i,dt:doc.title||void 0,dr:doc.referrer||void 0,sid:p(),sct:g(),seg:"1",en:u(),"epn.percent_scrolled":f(),"ep.search_term":C(),"ep.file_extension":e||void 0,"ep.file_name":t||void 0,"ep.link_text":n||void 0,"ep.link_url":o||void 0,_ss:O(),_dbg:A?1:void 0}),l=S+"?"+M;if(nav.sendBeacon)nav.sendBeacon(l);else{let e=new XMLHttpRequest;e.open("POST",l,!0)}}a();function sPr(){return(docEl.scrollTop||docBody.scrollTop)/((docEl.scrollHeight||docBody.scrollHeight)-docEl.clientHeight)*100}doc.addEventListener("scroll",sEv,{passive:!0});function sEv(){const e=sPr();if(e<90)return;enScroll=!0,a(),doc.removeEventListener("scroll",sEv,{passive:!0}),enScroll=!1}document.addEventListener("DOMContentLoaded",function(){let e=document.getElementsByTagName("a");for(let t=0;t<e.length;t++)if(e[t].getAttribute("href")!=null){const n=e[t].getAttribute("href"),s=n.substring(n.lastIndexOf("/")+1),o=s.split(".").pop();(e[t].hasAttribute("download")||extensions.includes(o))&&e[t].addEventListener("click",fDl,{passive:!0})}});function fDl(e){enFdl=!0;const t=e.currentTarget.getAttribute("href"),n=t.substring(t.lastIndexOf("/")+1),s=n.split(".").pop(),o=n.replace("."+s,""),i=e.currentTarget.text,r=t.replace(docLoc.origin,"");a(s,o,i,r),enFdl=!1}
// Version 1.10.200923
// Changelog:
// 1.10
// - added ability to track `<a href` links to files with specified extensions and all these links where there is a `download` attribute specified independently of the extension of the file.
//
// 1.09.1
// - minor changes of single quote in code (') to double quote (").
// 1.09
// - add listener for 90% scroll event. When user scrol to 90%+ of the page then script is fired again with scroll even and then (listener) terminates.
// - Specified global parameters and small tweak in search event.
// 1.08
// - replaced VAR with LET and moved new URLSearchParams, as caused issues when minified
// - changed Minified version from minify-js.com to go.tacodewolff.nl/minify as caused issues with higher number of new users than users
// 1.07
// - added event parameter (search_term) when tracking search via view_search_results event
// -- commented gtmId as currently not in use, hidden from minified version
// 1.06
// - added event identification when page view is a search to set as view_search_results, in other case page_view
// 1.05
// - Added _fv (first_visit indicator) based on cid_v4 in local storage for identification of returning users
// - Corrected (sr) screen resolution retection to include device Pixel ration (like Retina)
// - gtm - value convert to lower case
// - gtm - Google Tag Manager (GTM) Hash Info. If the current hit is coming was generated from GTM, it will contain a hash of current GTM/GTAG config
// 1.04
// - start new session (_ss) only when it is real new session (store session in sessionStorage); revert cid generation to previous method and store under cid_v4 to do not cause an issue when using minimal UA(v3) with this code.
// 1.03
// - split cid generation into two parts and combine at later stage
// 1.02
// - corrected generation of cid
// 1.01
// - changed method for generating _p, cid & sid
// 1.00
// - first release
enScroll = false;
enFdl = false;
extCurrent = undefined;
filename = undefined;
targetText = undefined;
splitOrigin = undefined;
const lStor = localStorage;
const sStor = sessionStorage;
const doc = document;
const docEl = document.documentElement;
const docBody = document.body;
const docLoc = document.location;
const w = window;
const s = screen;
const nav = navigator || {};
const extensions = ["pdf", "xls", "xlsx", "doc", "docx", "txt", "rtf", "csv", "exe", "key", "pps", "ppt", "pptx", "7z", "pkg", "rar", "gz", "zip", "avi", "mov", "mp4", "mpe", "mpeg", "wmv", "mid", "midi", "mp3", "wav", "wma"];
function a(extCurrent, filename, targetText, splitOrigin) {
// debug options to clean cache
// localStorage.clear();
// sessionStorage.clear();
const trackingId = "G-XXXXXXXXXX"; // set here your Measurement ID for GA4 / Stream ID
// gmt > If the current hit is coming was generated from GTM, it will contain a hash of current GTM/GTAG config, example: 2oear0
// Currently not in use, leave XXXXXX , under investigation
// let gtmId = "XXXXXX";
// if (gtmId == "XXXXXX") {
// let gtmId = undefined;
// }
// else {
// let gtmId = gtmId.toLowerCase();
// }
// 10-ish digit number generator
const generateId = () => Math.floor(Math.random() * 1000000000) + 1;
// UNIX datetime generator
const dategenId = () => Math.floor(Date.now() / 1000);
const _pId = () => {
if (!sStor._p) {
sStor._p = generateId();
}
return sStor._p;
};
const generatecidId = () => generateId() + "." + dategenId();
const cidId = () => {
if (!lStor.cid_v4) {
lStor.cid_v4 = generatecidId();
}
return lStor.cid_v4;
};
const cidCheck = lStor.getItem("cid_v4");
const _fvId = () => {
if(cidCheck) {
return undefined;
}
else if(enScroll==true) {
return undefined;
}
else {
return "1";
}
};
const sidId = () => {
if (!sStor.sid) {
sStor.sid = dategenId();
}
return sStor.sid;
};
const _ssId = () => {
if (!sStor._ss) {
sStor._ss = "1";
return sStor._ss;
}
else if(sStor.getItem("_ss") == "1") {
return undefined;
}
};
const generatesctId = "1";
const sctId = () => {
if (!sStor.sct) {
sStor.sct = generatesctId;
}
else if(enScroll==true) {
return sStor.sct;
}
else {
x = +sStor.getItem("sct") + +generatesctId;
sStor.sct = x;
}
return sStor.sct;
};
// Default GA4 Search Term Query Parameter: q,s,search,query,keyword
const searchString = docLoc.search;
const searchParams = new URLSearchParams(searchString);
//const searchString = "?search1=test&query1=1234&s=dsf"; // test search string
const sT = ["q", "s", "search", "query", "keyword"];
const sR = sT.some(si => searchString.includes("&"+si+"=") || searchString.includes("?"+si+"="));
const eventId = () => {
if (sR == true) {
return "view_search_results";
}
else if (enScroll == true) {
return "scroll";
}
else if (enFdl == true) {
return "file_download";
}
else {
return "page_view";
}
};
const eventParaId = () => {
if(enScroll==true) {
return "90";
}
else {
return undefined;
}
};
// get search_term
const searchId = () => {
if (eventId() == "view_search_results") {
//Iterate the search parameters.
for (let p of searchParams) {
//console.log(p); // for debuging
if (sT.includes(p[0])) {
return p[1];
}
}
}
else {
return undefined;
}
};
const encode = encodeURIComponent;
const serialize = (obj) => {
let str = [];
for (let p in obj) {
if (obj.hasOwnProperty(p)) {
if(obj[p] !== undefined) {
str.push(encode(p) + "=" + encode(obj[p]));
}
}
}
return str.join("&");
};
const debug = false; // enable analytics debuging
// url
const url = "https://www.google-analytics.com/g/collect";
// payload
const data = serialize({
v: "2", // Measurement Protocol Version 2 for GA4
tid: trackingId, // Measurement ID for GA4 or Stream ID
//gtm: gtmId, // Google Tag Manager (GTM) Hash Info. If the current hit is coming was generated from GTM, it will contain a hash of current GTM/GTAG config (not in use, currently under investigation)
_p: _pId(), // random number, hold in sessionStorage, unknown use
sr: (s.width * w.devicePixelRatio+"x"+s.height * w.devicePixelRatio).toString(), // Screen Resolution
ul: (nav.language || undefined).toLowerCase(), // User Language
cid: cidId(), // client ID, hold in localStorage
_fv: _fvId(), // first_visit, identify returning users based on existance of client ID in localStorage
_s: "1", // session hits counter
dl: docLoc.origin + docLoc.pathname + searchString, // Document location
dt: doc.title || undefined, // document title
dr: doc.referrer || undefined, // document referrer
sid: sidId(), // session ID random generated, hold in sessionStorage
sct: sctId(), // session count for a user, increase +1 in new interaction
seg: "1", // session engaged (interacted for at least 10 seconds), assume yes
en: eventId(), // event like page_view, view_search_results or scroll
"epn.percent_scrolled": eventParaId(),// event parameter, used for scroll event
"ep.search_term": searchId(), // search_term reported for view_search_results from search parameter
"ep.file_extension": extCurrent || undefined,
"ep.file_name": filename || undefined,
"ep.link_text": targetText || undefined,
"ep.link_url": splitOrigin || undefined,
_ss: _ssId(), // session_start, new session start
_dbg: debug ? 1 : undefined, // console debug
});
const fullurl = (url+"?"+data);
// for debug purposes
// console.log(data);
// console.log(url, data);
// console.log(fullurl);
if(nav.sendBeacon) {
nav.sendBeacon(fullurl);
} else {
let xhr = new XMLHttpRequest();
xhr.open("POST", (fullurl), true);
}
}
a();
// Scroll Percent
function sPr() {
return (docEl.scrollTop||docBody.scrollTop) / ((docEl.scrollHeight||docBody.scrollHeight) - docEl.clientHeight) * 100;
}
// add scroll listener
doc.addEventListener("scroll", sEv, { passive: true });
// scroll Event
function sEv() {
const percentage = sPr();
if (percentage < 90) {
return;
}
enScroll = true;
// fire analytics script
a();
// remove scroll listener
doc.removeEventListener("scroll", sEv, { passive: true });
enScroll = false;
}
// file download listener
document.addEventListener("DOMContentLoaded", function() {
let Anchors = document.getElementsByTagName("a");
for (let i = 0; i < Anchors.length; i++) {
if (Anchors[i].getAttribute("href") != null) {
const url = Anchors[i].getAttribute("href");
const file = url.substring(url.lastIndexOf("/") + 1);
const ext = file.split(".").pop();
/* if any anchor got download attribute, add event listener */
/* and if any anchor have acceptable extension */
if (Anchors[i].hasAttribute("download") || extensions.includes(ext)) {
Anchors[i].addEventListener("click", fDl, { passive: true });
}
}
}
});
// file download Event
function fDl(e) {
enFdl = true;
const urlCurrent = e.currentTarget.getAttribute("href");
const fileCurrent = urlCurrent.substring(urlCurrent.lastIndexOf("/") + 1);
const extCurrent = fileCurrent.split(".").pop();
const filename = fileCurrent.replace("." + extCurrent, "");
const targetText = e.currentTarget.text;
const splitOrigin = urlCurrent.replace(docLoc.origin, "");
// fire analytics script
a(extCurrent, filename, targetText, splitOrigin);
enFdl = false;
}
@mindplay-dk
Copy link

mindplay-dk commented Oct 24, 2024

I just found another bug - the scope and life time of the page ID (_p in the payload) appear to be incorrect.

If you look at your implementations of _pId and _ssId, these implementations are in fact identical.

The purpose of the page ID is to connect events that occurred on the same page - so the page ID should change when you navigate to a different page. The only time it shouldn't change, is when the same page sends multiple events - for example, if there's a scroll event after a page_view event on the same page, that is the only time the page ID should be the same.

I tested the GA script, and that is how it works.

For the purposes of this script, I think you can model the page ID simply as const _p = generateId(), and then just get rid of the _pId function entirely.

I was expecting history.pushState would change the page ID as well, since, logically, in an SPA, changing the URL could be interpreted as navigating to a new page - however, I tested this with the GA script, and that is not the case.

So the scope of the page ID is literally just that of a volatile var in the script running on the page. 🙂

EDIT: longer discussion and a bit of a detour below - this first part is definitely relevant, but you can ignore the part below here!


That said, I also discovered this page in the GA docs:

https://developers.google.com/analytics/devguides/collection/ga4/single-page-applications?implementation=browser-history

It appears you can configure the official GA script to record history.pushState (et al) as page_view events in an SPA - it's worth noting that changing this setting in your GA control panel won't do anything for a custom GA script like this one.

In other words, if you want the script to support SPAs, you would need to implement hooks into the history methods, something like:

let _p = generateId();

["pushState","replaceState"].forEach(name => {
  const method = history[name].bind(history);

  history[name] = function() {
    method(...arguments)
    _p = generateId();
    // TODO record a page_view
  };
});

EDIT: ehh, it probably needs more logic than this, since not every pushState or replaceState call is going to represent actual navigation - some apps update the URL a lot. On top of that, some SPAs don't use history state and prefer document.location.hash, which you can't detect with this approach.

This might be a can of worms we don't want to open. 😅

I'm going to drop this from my own version of the script and instead expose a track function, so that you can manually hook in your SPA router/framework and send page_view events as needed from there - a developer will probably know best what represents navigation and what doesn't. I don't know the official script implements that feature, but I don't think there's any safe heuristic for this.

@mindplay-dk
Copy link

The session hit counter _s is wrong as well - this should increase with each recorded event.

Again, "session" here means the same thing as the _p page ID, so it's literally just a local variable that increments with each event:

let _s = 1;

const data = {
  // ...
  _s: _s++,
  // ...
}

I tested the original GA script's behavior again, and this appears to be how it works. (whether for page_view or other events.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment