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;
}
@jahilldev
Copy link

@idarek Hey, thanks for the effort thus far on this. Good work!

I've been integrating this into my own project, re-writing in TypeScript, and I noticed the script above is missing the 90% scroll event. I've got this working now with my modified script, but you basically do something like this:

document.addEventListener('scroll', debounce(() => {
  const scrollPercentage = 0; // calculate this

  if (percentage < 90) {
    return;
  }

  track({ type: 'scroll', event: { 'epn.percent_scrolled': 90 } });

  document.removeEventListener('scroll', scrollEvent);
});

I'm using a generic track function, but all that does above is set the params en=scroll and epn.percent_scrolled=90

@idarek
Copy link
Author

idarek commented May 11, 2022

Appreciate @jahilldev. Adding a listener would open options for some other aspects of this analytics solution. Not sure if this is what will be expected from Minimal Analytics. The idea is to track and forget approach, so the analytics is not tracking users in the background but I think it all depends on what the end-user need. JavaScript and TypeScrypt is something that I am natively good at as learning along when creating something, but goods thing to start further development :)

ps. the script is missing also engagement time and other default events that are present in the full script.

@jahilldev
Copy link

@idarek I'm thinking of creating a series of packages that provide minimal implementations of popular analytics libraries, starting with GA4. I'd really like for you to be a contributor given you put in the initial work on this: https://github.com/jahilldev/minimal-analytics

Let me know if that's something you want to do 👍

the script is missing also engagement time and other default events that are present in the full script.

Yeh I've noticed this, I think I'm going to make things like this opt-in so users can either keep it super basic (and small), or add things like scroll tracking and engagement time, etc.

@sifigi4335
Copy link

@jahilldev your code looks promising (though I haven't figured out yet how to use it). Do you intend to include engagement time?

@jahilldev
Copy link

@carerragt Thanks 👍

Yes, it's not 100% finished yet, but once it is I'm going to attempt to cover all basic data, engagement, scroll, etc.

Once ready, I'll write the README with instructions and publish to NPM.

@idarek
Copy link
Author

idarek commented May 15, 2022

Hi @jahilldev happy to help if I can. Let's see how this will be going.

@jahilldev
Copy link

jahilldev commented May 17, 2022

@carerragt I've just published the first version of GA4, it tracks page_view, scroll (90%) and user_engagement by default.

https://github.com/jahilldev/minimal-analytics/tree/main/packages/ga4#readme

Feel free to open issues if you find any 👍

@sifigi4335
Copy link

@carerragt I've just published the first version of GA4, it tracks page_view, scroll (90%) and user_engagement by default.

https://github.com/jahilldev/minimal-analytics/tree/main/packages/ga4#readme

Feel free to open issues if you find any 👍

Alright! Thanks.

@xerc
Copy link

xerc commented Jun 29, 2022

hey @idarek , thank for your GAv4 JS just want to comment on mixed usage of quotes ; best would be only double-quote @ min ref.

@idarek
Copy link
Author

idarek commented Jun 29, 2022

hey @idarek , thank for your GAv4 JS just want to comment on mixed usage of quotes ; best would be only double-quote @ min ref.

Thanks. I do not control minifying process and relay on this. Noticed that some minifyers are breaking the code so found this to be working. Would like to not amend manually it. If you can point changes to be made in unminified version I would greatly appreciate.

@xerc
Copy link

xerc commented Jun 29, 2022

just search for ' and replace with " ; s/'/"/g

@idarek
Copy link
Author

idarek commented Jun 29, 2022

just search for ' and replace with " ; s/'/"/g

Done, thank you.

@WittyWidget
Copy link

Hi @idarek, could you please add the average engagement time?

@idarek
Copy link
Author

idarek commented Sep 10, 2022

Hi @idarek, could you please add the average engagement time?

It will be at some point, but that depend on my time availability. Next on my list is to add tracking for downloads using <a href with download> attribute before I look on engagement time.

I been looking into that for some time and found that it’s not as simple as you can imagine as there is more than one scenario to be considered like found by @jahilldev on his TypeScript GA4 equivalent.

It’s on my list for sure, just need to see when I will be able to take time on that in a pure JavaScript approach and simplified form.

@alfred-tuinman
Copy link

Thanks a ton Dariusz for posting this! I am delighted to use it.

@idarek
Copy link
Author

idarek commented May 9, 2023

Glad you like it. Still some work on this can be done but need to find time for that :)

@idarek
Copy link
Author

idarek commented Sep 21, 2023

To who is subscribing to the comments here. I just published version 1.10 which includes tracking of file downloads through specified extensions or download attributes in the link.

@xerc
Copy link

xerc commented Sep 27, 2023

@idarek thanks for the efford ; may think about a "wrapper" (like @ https://minimalanalytics.com) to prevent var leakage & params would be at the end for easier conf. at the user side

@idarek
Copy link
Author

idarek commented Sep 27, 2023

Thanks, @xerc I will have a look at that, however I need to learn about that. My knowledge is limited to what I am able to learn from examples over the internet. This is how I build this right now. Appreciate any links or pointing into some guides to help me with that.

@razonyang
Copy link

Thank you for your work. I'm interested in this. Is there a git repository for storing this script? So I can easily port it to other projects and maintain it without copying and pasting your code.

I noticed that the track ID is hardcoded in the script. If you're going to create a git repo for this script, I suggest fetching the track ID by attribute.

<script data-id="G-XXXXXXXX" src="mini-analytics.js"></script>
// mini-analytics.js
console.log(document.currentScript.getAttribute('data-id'));

Btw, what is the license of your code? I might do it myself and credit your work if you don't have time or no plan to do so.

Thanks.

@idarek
Copy link
Author

idarek commented Oct 10, 2024

Only Gist repo.
It's hardcoded on purpose to make it universal, so you can use it independently in the environment you are using.
Feel free to use it for your needs.
Crediting to my work will be enough.

Share your code when you can, as also will be happy to "borrow" some improvements.

@razonyang
Copy link

Crediting to my work will be enough.

I mentioned this Gist on README and add your name to the LICENSE.

Share your code when you can, as also will be happy to "borrow" some improvements.

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.

@mindplay-dk
Copy link

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?

@mindplay-dk
Copy link

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.)

@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