Get startedAdd the script
Get started
Add the script
Paste odo’s script into every page: the tag and its options, leaving out your own visits, cookieless mode, the early snippet, and ad blockers.
On this page
odo measures your website with one small script of its own: no other company’s code, and no tag manager needed.
Add your website#
In odo, open Settings → Websites and add your site’s domain. odo gives the site an id and shows its tag. Paste the tag into the <head> of every page:
<script defer src="https://odo.gg/js/odo.js" data-site="SITE_ID"></script>The tag in odo has your site’s id in place of SITE_ID. Subdomains count as the same site, so www. and app. need nothing more. Other hosts that should count too (a second domain, a staging host) and paths to leave out (/admin/*) are set per site, in the same place.
The script is deferred, so it never holds up your page, and it sends what it measures with sendBeacon, so a visitor leaving doesn’t lose their last page.
Options on the tag#
| Attribute | What it does |
|---|---|
data-site | Required. The site’s id. |
data-api | Where hits go. Only needed when you serve the script from your own domain and the collector isn’t at /api/event beside it. |
data-hash | Counts #/routes as separate pages, for apps that route with the hash. |
data-local | Also measures on localhost, 127.0.0.1 and .local hosts. Off by default, so development doesn’t count. |
data-cookieless | Never stores an id in the browser. See below. |
data-capture | Which of clicks, forms, errors, sections, vitals (page speed) and heat (heatmaps) to record, or none. All of them without it. See clicks, forms and errors. |
Leave out your own visits#
Open your site once with ?odo=ignore on the address: https://example.com/?odo=ignore. That browser stops being counted on that site, and its console says so. ?odo=track counts it again.
Automated browsers aren’t counted either: headless Chrome, Lighthouse, Cypress, PhantomJS and any browser that says a program is driving it. A page the browser prerenders counts only once someone actually sees it.
Cookieless, and Global Privacy Control#
By default the script keeps one first-party id, odo_vid, in a cookie and in local storage on your own domain. It’s how odo recognizes a returning visitor, keeps each one on the same version of an experiment, and lets a checkout pass the visitor to your payment platform, so the sale is traced to the visit.
A cookieless site stores nothing in the browser. Turn on Cookieless in the website’s settings and add data-cookieless to the tag (only the tag stops a browser that already has the script from storing the id):
<script defer src="https://odo.gg/js/odo.js" data-site="SITE_ID" data-cookieless></script>Visitors are then counted by a hash of the site, their IP address and their browser, salted with a secret that’s replaced every day and then deleted, so one day’s visits can’t be linked to the next. A returning visitor is recognized within a day only, sales can’t be traced to visits, and experiments don’t run.
Browsers that send Global Privacy Control are treated that way on every site, whatever its setting. More in privacy and data.
The early snippet#
Because the script is deferred, your own code can run before it. If that code calls odo() or odo.experiment(), paste the early snippet right after odo’s tag and before your own scripts. It answers odo.experiment() at once, with the variant odo.js will give, queues events until the script arrives, and keeps working where the script is blocked.
<script>"use strict";(()=>{var T={perBeacon:30,perPage:100,errorsPerPage:10,label:120,element:120,section:80,maxAgoMs:15*6e4,experiment:60,variant:40},m=/^[\w .:-]{1,60}$/,f=/^[\w .:-]{1,40}$/;function _(t){let e=2166136261;for(let n=0;n<t.length;n++)e^=t.charCodeAt(n),e=Math.imul(e,16777619);return e>>>0}function x(t,e,n,i){if(!n.length)return null;let u=_(`${t}:${e}`)/4294967296,r=i&&i.length===n.length&&i.every(o=>Number.isFinite(o)&&o>=0)&&i.some(o=>o>0)?i:n.map(()=>1),c=r.reduce((o,l)=>o+l,0),s=0;for(let o=0;o<n.length;o++)if(s+=r[o]/c,u<s)return n[o];return n[n.length-1]}var S="[data-odo-variant][hidden]{display:none!important}";function p(t=S){try{let e=document.createElement("style");document.head.appendChild(e),e.sheet.insertRule(t)}catch(e){}}function h(t,e,n,i,u){let a=typeof e=="string",r=a?[e]:e||[];if(!m.test(t)||!r[0]||!r.every(o=>f.test(o)))return{variant:r[0]||null};let c=(u.find(o=>o.startsWith(`${t}:`))||"").slice(t.length+1);if(c&&(a||r.includes(c)))return{variant:c,valid:!0};let s=a?e:i&&x(i,t,r,n);return s?{variant:s,exposed:!0,valid:!0}:{variant:r[0],valid:!0}}var y=()=>(new URLSearchParams(location.search).get("odo_variant")||"").split(",");var d={get(t){try{return localStorage.getItem(t)}catch(e){return null}},set(t,e){try{e===null?localStorage.removeItem(t):localStorage.setItem(t,e)}catch(n){}}},w=()=>(/(?:^|;\s*)odo_vid=([\w-]{12,40})/.exec(document.cookie)||[])[1];var I=()=>{var t;return(t=window.crypto)!=null&&t.randomUUID?window.crypto.randomUUID().replace(/-/g,""):Array.from({length:24},()=>(Math.random()*36|0).toString(36)).join("")};function E(){let t=w()||d.get("odo_vid");return t&&/^[\w-]{12,40}$/.test(t)?t:null}(()=>{let t=window,e=document,n=t.odo||function(){n.q=n.q||[],n.q.push(arguments)};if(n.experiment)return;let i=[];n.x=i,n.experiment=(a,r,c)=>{let s=t.odo;if(s!==n&&s.experiment)return s.experiment(a,r,c);let o=!navigator.globalPrivacyControl&&!e.querySelector("script[data-site][data-cookieless]"),l=o?E():null;o&&!l&&e.querySelector("script[data-site]")&&(l=I(),d.set("odo_vid",l));let{variant:g,exposed:b}=h(a,r,c,l,y());return b&&i.push([a,g,typeof r=="string"?"":l]),g},t.odo=n,p();let u=d.get("odo_h");u&&(e.documentElement.dataset.odoPath=location.pathname,u.split(" ").map(p))})();})();</script>It also keeps the elements that experiments odo runs change out of sight until odo has changed them, for 800 ms at most, so returning visitors don’t see the original flash first. Without experiments, you don’t need it. It’s in Settings → Websites in odo too. If your site sends a Content Security Policy, allow this inline script by its hash.
Calling odo before it loads#
Without the early snippet, this one line queues calls until the script arrives:
<script>window.odo=window.odo||function(){(odo.q=odo.q||[]).push(arguments)}</script>Once the script has run, window hears an odo:ready event.
Ad blockers#
Some blockers stop analytics scripts that load from another domain. Serve the script and the collector from your own domain instead. With nginx:
location = /js/odo.js { proxy_pass https://odo.gg/js/odo.js; }
location = /api/event { proxy_pass https://odo.gg/api/event; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }For heatmaps and experiments odo runs, forward these the same way: the script loads them from beside itself.
location = /js/heatmap.js { proxy_pass https://odo.gg/js/heatmap.js; }
location = /api/heatmap { proxy_pass https://odo.gg/api/heatmap; }
location = /js/odo-x.js { proxy_pass https://odo.gg/js/odo-x.js; }
location /js/x/ { proxy_pass https://odo.gg/js/x/; }Then load it from there. The script sends hits to /api/event beside wherever it was loaded from, so data-api only matters when your paths differ:
<script defer src="/js/odo.js" data-site="SITE_ID" data-api="/api/event"></script>