-
-
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; | |
} |
Nice work, thanks! 🙂👍
I am wondering about session tracking though.
First off, values in sessionStorage
do not have the same semantics as a session cookie. In Chrome, they do not disappear when you close the tab or the window - even if you exit the browser, session values are back when you open the browser again.
Secondly, values in sessionStorage
are not shared between tabs. This was particularly surprising to me. If someone decides to open a link a new tab, the new tab has it's own sessionStorage
.
Every tab has it's own sessionStorage
, so a "session" in the context of sessionStorage
is different from a "session" in GA, I think?
According to what I could find about GA, opening multiple tabs or windows to your site from the same browser is tracked as a single session, not separate sessions - so sessionStorage
doesn't seem correct for this. It should probably all be in localStorage
. (?)
Also, according to this source, a "session" in GA ends after 30 minutes of inactivity, at midnight, or "When the user’s campaign origin is changed", whatever that means.
Personally, I would think it's totally reasonable to just cycle the session if the last recorded event was more than 30 minutes ago?
Either way, sessions can definitely cross tabs, so everything should probably use localStorage
rather than sessionStorage
?
You're also recording the screen resolution incorrectly:
(screen.width + "x" + screen.height) // '2752x1152' (what the real GA script sends)
(screen.width * window.devicePixelRatio + "x" + screen.height * window.devicePixelRatio) // '3440x1440' (yours)
The reason they record the size in logical pixels (and not in device pixels) is because these are comparable in terms of physical size. (It doesn't look like they record the DPI or the device pixels at all.)
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:
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.
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.)
I mentioned this Gist on README and add your name to the LICENSE.
I created a Hugo Module, and applied it to three of my websites last week. It's working fine so far.
Thank you again for your excellent work.