NoticePublishing freeze Friday, Aug 14, 3–6 pm ET while the CMS migration runs.
Documentation home/
Governance and ops
/
UTM Tracker Footer Script

UTM Tracker Footer Script

A plain-language walkthrough of the UTM attribution script in the site footer: what each part does, how the Osano consent gate works, and why the cookie can silently fail to save.

Before you beginYou need Editor access to the CAS.org site. Anything published to the live site is reviewed by the web team first.

Last Updated: August 24, 2026 | Audience: Developers

What this script does

When someone clicks a CAS ad or a tracked link, the URL carries information about where they came from:

cas.org/solutions?utm_source=google&utm_medium=cpc&utm_campaign=scifinder-q3

Those utm_ pieces are UTM parameters. This one says the visitor arrived from a Google paid search ad for the SciFinder Q3 campaign.

Here is the problem it solves. Visitors almost never fill out a form on the page they land on. They browse, read a few things, and submit a form ten minutes later. By then the UTM parameters are gone from the address bar and the submission looks like it came from nowhere.

This script grabs those parameters the moment someone lands, stores them in a browser cookie, and later copies them into hidden fields on whatever form that person eventually fills out. That is how a lead gets connected back to the campaign that produced it.

Where the code lives

Site Settings > Custom Code > Footer Code. It runs on every page. The block has three parts in this order:

<script>var UTMTracker = function () { /* minified. do not edit this line. */ }();/* Patched methods that replace broken ones inside the class above */UTMTracker.prototype.saveParamsCookie = function () { ... };UTMTracker.prototype.getSavedParams   = function () { ... };/* Startup and consent gate */(function () { ... })();</script>

Do not reformat the first line. It is minified, meaning whitespace and variable names were stripped to save space. Reformatting risks changing behavior for no benefit. When something inside it needs fixing, override the method underneath instead. That is exactly what the patched methods do.

The journey of a UTM parameter

  1. Visitor clicks an ad and lands on cas.org with UTM parameters in the URL.
  2. document.cookie.split('; ').filter(c => c.startsWith('urlParameters'))
  3. It checks whether the visitor consented to analytics. If not, it stops here.
  4. It reads the parameters from the URL and cleans them.
  5. It writes them into a cookie named urlParameters that lives for 90 days.
  6. The visitor browses on. The URL loses the parameters. The cookie keeps them.
  7. They reach a page with a form. The script reads the cookie and fills hidden fields.
  8. They submit. The attribution data rides along with the submission.

The code, piece by piece

The settings

The constructor defines the cookie and three lists of parameters to watch for:

this.cookieExpDays    = 90;this.cookieName       = 'urlParameters';this.requiredParams   = ['utm_source', 'utm_medium', 'utm_campaign'];this.optionalParams   = ['utm_term', 'utm_content'];this.adPlatformParams = ['gclid', 'msclkid', 'fbclid'];


One naming trap: requiredParams is a misleading name. Nothing actually requires them. Every parameter is optional, and a URL carrying only utm_source works fine.

The last list holds ad platform click IDs. Google, Bing, and Facebook each append their own when someone clicks a paid ad.

parseURLParams()

Reads the current URL and builds a plain object of whatever it finds. Anything absent is simply left out. Reformatted for readability:

var search = new URLSearchParams(window.location.search);
var out = {};

this.requiredParams.forEach(function (key) {
 var value = search.get(key);
 if (value) out[key] = this.sanitizeValue(value);
}.bind(this));

// Same loop again for optionalParams, then for adPlatformParams,
// which additionally sets out.lta = 'Google Ads' (or Bing / Facebook).

if (!out.lta && document.referrer) {
 out.lta = new URL(document.referrer).hostname;
}

return out;

lta is short for last touch attribution. If a click ID is present it becomes a friendly platform name. If not, it falls back to the hostname of whatever site referred the visitor. If more than one click ID is somehow present, the last one processed wins.

sanitizeValue()

return decodeURIComponent(value)
 .replace(/[<>{}]/g, '')
 .substring(0, 255);

These values arrive from the URL, which anyone can edit, and they end up written into the page and into a cookie. So they get decoded, stripped of characters used in markup injection, and capped at 255 characters.

Note it does not strip semicolons. A semicolon terminates a cookie value, which is why the value has to be encoded separately when it is saved.

saveParamsCookie()

UTMTracker.prototype.saveParamsCookie = function () {
 var params = this.parseURLParams();
 if (!Object.keys(params).length) return;

 document.cookie = this.cookieName + '='
   + encodeURIComponent(JSON.stringify(params))
   + '; expires=' + this.getCookieExpiration()
   + '; path=/; SameSite=Strict';
};

Two behaviors worth understanding:

  • No parameters found means nothing happens. That early return is deliberate. An ordinary page visit with a clean URL will not wipe out a campaign the visitor arrived with earlier.
  • Parameters found overwrite whatever was there. The most recent campaign wins and sticks for 90 days.

path=/ makes the cookie readable on every page. SameSite=Strict means the browser will not attach it to requests originating from other websites, which is fine here because only our own JavaScript reads it.

getSavedParams()

var prefix = this.cookieName + '=';
var entry  = document.cookie.split('; ').find(function (c) {
 return c.indexOf(prefix) === 0;
});

if (!entry) return null;
return JSON.parse(decodeURIComponent(entry.slice(prefix.length)));

entry.slice(prefix.length) takes everything after the first =. The whole thing sits inside a try/catch that returns null on anything unexpected, so a damaged cookie never breaks the page.

pushToForms()

This is where the stored data actually gets used. Each value maps to a CSS class:

{
 utm_source:   'hiddenCampaignsource',
 utm_medium:   'hiddenCampaignmedium',
 utm_campaign: 'hiddenCampaignname',
 utm_content:  'hiddenCampaigncontent',
 utm_term:     'utm_term',
 lta:          'last_touch_attribution'
}

And for each one it does this:

document.querySelectorAll('.' + className + ' input[type="text"]')
 .forEach(function (input) { input.value = value; });

Read that selector carefully. The class sits on a wrapper, and the input is nested inside it. That is why the code searches within the wrapper rather than setting a value on the matched element directly.

Known limitation: this runs once at startup. A form injected into the page later by an async embed may not get filled. If attribution arrives empty on one specific form, check whether that form loads after the script runs.

The startup routine

(function () {
 var tracker = new UTMTracker({ cookieExpDays: 90 });

 function sync(consent) {
   if (consent && consent.ANALYTICS === 'ACCEPT') {
     tracker.saveParamsCookie();
     tracker.pushToForms();
   }
 }

 function bind() {
   if (typeof Osano === 'undefined' || !Osano.cm) return;
   try { sync(Osano.cm.getConsent()); } catch (e) {}
   Osano.cm.addEventListener('osano-cm-consent-changed', sync);
 }

 if (document.readyState === 'loading') {
   document.addEventListener('DOMContentLoaded', bind);
 } else {
   bind();
 }
})();

It reads consent once on load, then subscribes to changes so that accepting the banner later still captures the data without a page reload. It deliberately does not wait for the osano-cm-initialized event, because Osano does not replay that event to anything that subscribes after it has already fired.

Why the consent gate exists

Storing campaign data about a person is tracking, and CAS serves visitors in regions where that requires permission first. Osano is the platform that asks the question and records the answer.

Osano.cm.getConsent() returns an object of categories:

{
 ESSENTIAL:       'ACCEPT',
 STORAGE:         'ACCEPT',
 MARKETING:       'ACCEPT',
 PERSONALIZATION: 'ACCEPT',
 ANALYTICS:       'ACCEPT',
 OPT_OUT:         'DENY'
}

The trap: getConsent() ignores any argument you pass it.

// Wrong. The argument is ignored, the full object comes back,
// and an object is always truthy, so this gate always opens.
if (Osano.cm.getConsent('analytics')) { ... }

// Right.
var consent = Osano.cm.getConsent();
if (consent && consent.ANALYTICS === 'ACCEPT') { ... }

This was a real bug on the site, not a hypothetical. Always read the property off the returned object.

The Osano cookie blocker

This is the single most confusing thing about working on this script, so read it before debugging anything.

Osano replaces the browser's cookie mechanism with its own. Every write is inspected and checked against a list of known cookies in the Osano dashboard. A cookie whose name is not on that list is dropped.

What makes it painful is how quiet the failure is. No error. No console warning. The code appears to run perfectly. The cookie simply never exists.

So if urlParameters is not registered in the Osano dashboard, this entire script does nothing, no matter how correct the JavaScript is and no matter what the visitor consented to.

To check whether blocking is what is biting you, write a throwaway cookie in the console:

document.cookie = 'probe=1; path=/';
document.cookie.includes('probe');   // false means cookies are blocked

If that returns false, the fix belongs in the Osano dashboard, not in this code.

Three bugs fixed in August 2026

  1. The consent gate never gated. The old code called getConsent('analytics') and treated the result as a yes or no. Because that always returns a truthy object, data was collected regardless of what the visitor chose.
  2. The cookie value was not encoded. The old code wrote JSON.stringify(params) straight into the cookie. JSON contains commas and can contain semicolons, and a semicolon ends a cookie value, so any campaign name containing one would silently truncate the cookie into garbage.
  3. Reading the cookie split on every equals sign. The old code used entry.split('=')[1], which grabs only the text between the first and second =. Any stored value containing an equals sign was cut short.

How to test it

  1. Visit a published page with parameters attached, such as ?utm_source=test&utm_campaign=demo.
  2. Accept analytics in the Osano banner if it appears.
  3. Open the console and run this:

document.cookie.split('; ').filter(c => c.startsWith('urlParameters'))

  1. You should get one entry back. An empty array means you should work through the Osano cookie blocker section above before touching any JavaScript.
  2. Navigate to a page with a form, inspect a hidden field wrapper, and confirm the input inside it has a value.
  3. Decline analytics and repeat. No cookie should be written.

Common mistakes

  • Assuming the JavaScript is broken when the cookie is missing. Check Osano classification first. It is the more likely cause and takes thirty seconds to rule out.
  • Passing a category name to getConsent(). It ignores the argument.
  • Reformatting the minified line. Override methods underneath it instead.
  • Testing in a browser that already has consent saved. You are only exercising the accepted path. Use a private window to see what a first-time visitor gets.
  • Expecting attribution on forms that load late. Fields are filled once at startup.
  • Assuming a clean URL clears the cookie. It does not. The cookie persists for 90 days or until a new campaign overwrites it.

Quick reference

  • Location: Site Settings > Custom Code > Footer Code
  • Cookie name: urlParameters
  • Cookie lifetime: 90 days
  • Consent required: Osano ANALYTICS must equal ACCEPT
  • Hard requirement: urlParameters must be registered in the Osano dashboard or nothing saves
  • Captures: utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, msclkid, fbclid
  • Failure mode to expect: silent, with no console error
Was this guide helpful?YesNo