Published 2022-03-31.
Last modified 2025-09-16.
Time to read: 7 minutes.
posts
collection, categorized under Jekyll, Microsoft, SEO, Software-Expert.
I spent a fair bit of time designing the plugins for this Jekyll-powered website for SEO. These plugins are published as open source; click on the word Jekyll in the sentence above this paragraph to learn more. You are welcome!
I do not use Google Analytics because it slows down page load time dramatically. I do use Google Search Console, however, and recently started trying out Microsoft Bing Webmaster Tools.
Microsoft Clarity and Hotjar
The day before April Fool's Day in 2022, I stumbled across Microsoft Clarity, a free product that I knew nothing about. I was curious to know what benefit it might provide.
I learned that one of the things it can do is provide videos of actual user sessions as they interact with the website. Those videos are fantastic.
As a writer of a lot of online content, I have spent hours watching you, dear readers. Individually yet anonymously. Watching people read teaches you a lot about what is important to them. As a writer, knowing your audience allows you to be more relevant. BTW, I do not know who or where my readers are, just the country they are in.
Check out this video!
This is one of the first user sessions that Microsoft Clarity recorded for this website.
As you can see, Microsoft Clarity lets me watch movies of users clicking and scrolling through my website; spooky yet very informative.
Clarity is open source. Here is the GitHub project.
I have since learned that Hotjar is similar to Microsoft Clarity.
Online Behavior Matches Real-World Behavior
The user in the above video read a bit about my experience as a software expert witness, then straightaway downloaded my resume. They were on the website for just over one minute. Although they did call me, their online behavior showed a lack of urgency, and their ‘real-world’ behavior mirrored what I saw in the movie.
A week later another party visited my site, and spent 80 minutes carefully reading 3 pages, among others. Their ‘real-world’ behavior also matched their online behavior, in that they exhibited a sense of urgency towards engaging a software expert.
Tracking Downloads And Other Behavior
Microsoft Clarity does not consider a download as a click. It does not even notice downloads. For me, downloads are what I care about most. If you never download my resume, you probably are not a candidate for hiring me as a software expert. I want to track downloads, not clicks. Clicks are nice, but there is a direct relationship between resume downloads and signing contracts with legal firms who represent new clients.
AWS CloudTrail and CloudWatch can provide download details and much more. For example, user IP addresses and geographic location can be captured when a monitored event occurs.
Many Websites Perform Surveillance
Wired Magazine published an article on a similar type of surveillance (keyloggers) on May 11, 2022. I paraphrased two sentences from that article:
- 1.8% of websites studied gathered an EU user's email address without their consent, and a staggering 2.95% logged a US user's email.
- For US users, 8.4% of sites may have been leaking data to Meta, Facebook’s parent company, and 7.4% of sites may be impacted for EU users.
Yours truly does nothing of the sort.
Update 2023-07-10
I no longer need to watch the videos of user action. Microsoft Clarity's AI generates summaries. Often it yields incredibly actionable information, but the Key Takeaways section is generally way off the mark. YMMV.
Today I noticed the insights tab, visible when viewing an entry for a ‘hit’. Seems like Microsoft has employed AI to generate the following impressive summary of the user's behavior. I added punctuation, a light edit and a whimsical pad of notepaper.
Visited URL matches regex: ^https://www\.mslinn\.com/softwareexpert/index\.html(\?.*)?$
Last 7 days
Session insights
Some key takeaways from this session are:
The user was interested in the software expert witness and computer expert services offered by Mike Slinn, as they visited the homepage from Google and spent about four minutes there.
The user was also curious about the technology expert article series, especially the ones related to Git and version control systems. They visited the article index page multiple times and clicked on several articles, spending about 15 minutes in total on this topic.
The user was most engaged with the article on libgit2, a library that provides low-level access to Git operations. They spent about one and a half minutes on this article and clicked on a link to git-fame, a tool that shows the contribution statistics of a Git repository.
The user frequently resized their browser window, which may indicate that they were using a mobile device or adjusting their screen for better readability.
The user also switched between tabs or applications often, as indicated by the page hidden and page visible events. This may suggest that they were multitasking or comparing information from different sources.
01:47 / 47:31
Update 2023-07-18
Today I discovered the Clarity Live plugin for the Google Chrome web browser. There is no such plugin for Firefox, sadly.
- GDPR & CCPA ready
- No sampling
- Built on open source
Update 2023-10-04
I just stumbled across the Clarity Client API.
You can quickly get started with Clarity without coding but by interacting with the Clarity client API. This API can help you access advanced features as described in this reference. Add the following calls to Clarity APIs to the HTML or JavaScript of your webpage to access these features.
Note
Your Clarity ID serves as your API key. No other client API key is necessary, and there is no cost for using Clarity client APIs.
Popular Pages
The data that Clarity manages can be downloaded with the
Clarity Data Export API.
API calls are limited to 10 calls per project, per day; that is not a problem.
The endpoint is www.clarity.ms/export-data/api/v1/project-live-insights
.
An access token is required to use the API.
To get one, open the Clarity project and select
Settings -> Data Export -> Generate new API token.
I called my token clarity_data_export
and stored it in an environment
variable called CLARITY_DATA
Here is how I retrieved the information about the most popular pages in the
last 30 days using the bash command line.
This bash script requires jq
to be installed:
#!/bin/bash DAYS=30 DIM1="PopularPages" PARAMS="\numOfDays=$DAYS&dimension1=$DIM1" # Backslash escape URL="https://www.clarity.ms/export-data/api/v1/project-live-insights" # curl -s \ # --trace - \ # --location "$URL?$PARAMS" \ # --header 'Content-Type: application/json' \ # --header "Authorization: Bearer $CLARITY_DATA" | less # exit JSON=$(curl -s \ --location "$URL?$PARAMS" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $CLARITY_DATA" ) echo "$JSON" | jq -r '.[] | select(.metricName == "PopularPages").information[].url'
The result is something like the following:
https://www.mslinn.com/blog/2023/09/14/boost.html https://www.mslinn.com/blog/2021/02/11/javascript-named-arguments.html https://www.mslinn.com/blog/2023/08/20/pytest.html https://www.mslinn.com/blog/2022/01/10/wsl-backup.html https://www.mslinn.com/av_studio/640-music21.html https://www.mslinn.com/llm/4000-llm-wsl.html https://www.mslinn.com/blog/2021/04/28/buildah-podman.html https://www.mslinn.com/blog/2020/10/27/installing-a-new-ssh-key-on-awc-ec2-with-user-data.html https://www.mslinn.com/av_studio/570-ableton-push-standalone.html https://www.mslinn.com/git/600-partial-clone.html
Update 2025-09-09: Cookie Consent Requirements
I received the following email from Microsoft Clarity for each of my domains.
Subject:
Cookie Consent Requirements – updated timeline & action
|
From:
Microsoft Clarity <maccount@microsoft.com>
|
Date:
2025-08-27, 8:33 AM
|
To:
mslinn@mslinn.com
|
| ||
|
This looked like it might be a lot of work. The simplest free solution is detailed below, following these images of what the solution will look like.
The image is taken from a Clarity recording of a an anonymous Norweigan user session. The orange trail shows the path of the user's mouse.
Stylistic changes can be made by editing
cookie-consent-script.js
, shown below.
To meet Microsoft Clarity’s cookie consent requirements without paying for a third-party CMP like CookieYes or relying on Google Tag Manager, you can use a custom JavaScript solution with the Clarity Consent API and a free, open-source consent banner library like CookieConsent. This approach is straightforward, and merely requires adding a consent banner, and calling Clarity’s Consent API to signal user consent for EEA/UK/Switzerland.
CookieConsent stores user consent for up to a year.
Below is the step-by-step implementation guide. Excellent documentation is also available here.
Add CookieConsent JavaScript
I had previously installed the Clarity JavaScript. Cookie consent requires two additional bits of JavaScript:
- The
cookieconsent
library (formats to 600 lines of JavaScript):Formatted cookieconsent.min.js! function(e) { if (!e.hasInitialised) { var t = { escapeRegExp: function(e) { return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&") }, hasClass: function(e, t) { var i = " "; return 1 === e.nodeType && (i + e.className + i).replace(/[\n\t]/g, i).indexOf(i + t + i) >= 0 }, addClass: function(e, t) { e.className += " " + t }, removeClass: function(e, t) { var i = new RegExp("\\b" + this.escapeRegExp(t) + "\\b"); e.className = e.className.replace(i, "") }, interpolateString: function(e, t) { var i = /{{([a-z][a-z0-9\-_]*)}}/gi; return e.replace(i, function(e) { return t(arguments[1]) || "" }) }, getCookie: function(e) { var t = "; " + document.cookie, i = t.split("; " + e + "="); return i.length < 2 ? void 0 : i.pop().split(";").shift() }, setCookie: function(e, t, i, n, o, s) { var r = new Date; r.setDate(r.getDate() + (i || 365)); var a = [e + "=" + t, "expires=" + r.toUTCString(), "path=" + (o || "/")]; n && a.push("domain=" + n), s && a.push("secure"), document.cookie = a.join(";") }, deepExtend: function(e, t) { for (var i in t) t.hasOwnProperty(i) && (i in e && this.isPlainObject(e[i]) && this.isPlainObject(t[i]) ? this.deepExtend(e[i], t[i]) : e[i] = t[i]); return e }, throttle: function(e, t) { var i = !1; return function() { i || (e.apply(this, arguments), i = !0, setTimeout(function() { i = !1 }, t)) } }, hash: function(e) { var t, i, n, o = 0; if (0 === e.length) return o; for (t = 0, n = e.length; t < n; ++t) i = e.charCodeAt(t), o = (o << 5) - o + i, o |= 0; return o }, normaliseHex: function(e) { return "#" == e[0] && (e = e.substr(1)), 3 == e.length && (e = e[0] + e[0] + e[1] + e[1] + e[2] + e[2]), e }, getContrast: function(e) { e = this.normaliseHex(e); var t = parseInt(e.substr(0, 2), 16), i = parseInt(e.substr(2, 2), 16), n = parseInt(e.substr(4, 2), 16), o = (299 * t + 587 * i + 114 * n) / 1e3; return o >= 128 ? "#000" : "#fff" }, getLuminance: function(e) { var t = parseInt(this.normaliseHex(e), 16), i = 38, n = (t >> 16) + i, o = (t >> 8 & 255) + i, s = (255 & t) + i, r = (16777216 + 65536 * (n < 255 ? n < 1 ? 0 : n : 255) + 256 * (o < 255 ? o < 1 ? 0 : o : 255) + (s < 255 ? s < 1 ? 0 : s : 255)).toString(16).slice(1); return "#" + r }, isMobile: function() { return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) }, isPlainObject: function(e) { return "object" == typeof e && null !== e && e.constructor == Object }, traverseDOMPath: function(e, i) { return e && e.parentNode ? t.hasClass(e, i) ? e : this.traverseDOMPath(e.parentNode, i) : null } }; e.status = { deny: "deny", allow: "allow", dismiss: "dismiss" }, e.transitionEnd = function() { var e = document.createElement("div"), t = { t: "transitionend", OT: "oTransitionEnd", msT: "MSTransitionEnd", MozT: "transitionend", WebkitT: "webkitTransitionEnd" }; for (var i in t) if (t.hasOwnProperty(i) && "undefined" != typeof e.style[i + "ransition"]) return t[i]; return "" }(), e.hasTransition = !!e.transitionEnd; var i = Object.keys(e.status).map(t.escapeRegExp); e.customStyles = {}, e.Popup = function() { function n() { this.initialise.apply(this, arguments) }
function o(e) { this.openingTimeout = null, t.removeClass(e, "cc-invisible") }
function s(t) { t.style.display = "none", t.removeEventListener(e.transitionEnd, this.afterTransition), this.afterTransition = null }
function r() { var t = this.options.onInitialise.bind(this); if (!window.navigator.cookieEnabled) return t(e.status.deny), !0; if (window.CookiesOK || window.navigator.CookiesOK) return t(e.status.allow), !0; var i = Object.keys(e.status), n = this.getStatus(), o = i.indexOf(n) >= 0; return o && t(n), o }
function a() { var e = this.options.position.split("-"), t = []; return e.forEach(function(e) { t.push("cc-" + e) }), t }
function c() { var e = this.options, i = "top" == e.position || "bottom" == e.position ? "banner" : "floating"; t.isMobile() && (i = "floating"); var n = ["cc-" + i, "cc-type-" + e.type, "cc-theme-" + e.theme]; e["static"] && n.push("cc-static"), n.push.apply(n, a.call(this)); p.call(this, this.options.palette); return this.customStyleSelector && n.push(this.customStyleSelector), n }
function l() { var e = {}, i = this.options; i.showLink || (i.elements.link = "", i.elements.messagelink = i.elements.message), Object.keys(i.elements).forEach(function(n) { e[n] = t.interpolateString(i.elements[n], function(e) { var t = i.content[e]; return e && "string" == typeof t && t.length ? t : "" }) }); var n = i.compliance[i.type]; n || (n = i.compliance.info), e.compliance = t.interpolateString(n, function(t) { return e[t] }); var o = i.layouts[i.layout]; return o || (o = i.layouts.basic), t.interpolateString(o, function(t) { return e[t] }) }
function u(i) { var n = this.options, o = document.createElement("div"), s = n.container && 1 === n.container.nodeType ? n.container : document.body; o.innerHTML = i; var r = o.children[0]; return r.style.display = "none", t.hasClass(r, "cc-window") && e.hasTransition && t.addClass(r, "cc-invisible"), this.onButtonClick = h.bind(this), r.addEventListener("click", this.onButtonClick), n.autoAttach && (s.firstChild ? s.insertBefore(r, s.firstChild) : s.appendChild(r)), r }
function h(n) { var o = t.traverseDOMPath(n.target, "cc-btn") || n.target; if (t.hasClass(o, "cc-btn")) { var s = o.className.match(new RegExp("\\bcc-(" + i.join("|") + ")\\b")), r = s && s[1] || !1; r && (this.setStatus(r), this.close(!0)) } t.hasClass(o, "cc-close") && (this.setStatus(e.status.dismiss), this.close(!0)), t.hasClass(o, "cc-revoke") && this.revokeChoice() }
function p(e) { var i = t.hash(JSON.stringify(e)), n = "cc-color-override-" + i, o = t.isPlainObject(e); return this.customStyleSelector = o ? n : null, o && d(i, e, "." + n), o }
function d(i, n, o) { if (e.customStyles[i]) return void++e.customStyles[i].references; var s = {}, r = n.popup, a = n.button, c = n.highlight; r && (r.text = r.text ? r.text : t.getContrast(r.background), r.link = r.link ? r.link : r.text, s[o + ".cc-window"] = ["color: " + r.text, "background-color: " + r.background], s[o + ".cc-revoke"] = ["color: " + r.text, "background-color: " + r.background], s[o + " .cc-link," + o + " .cc-link:active," + o + " .cc-link:visited"] = ["color: " + r.link], a && (a.text = a.text ? a.text : t.getContrast(a.background), a.border = a.border ? a.border : "transparent", s[o + " .cc-btn"] = ["color: " + a.text, "border-color: " + a.border, "background-color: " + a.background], a.padding && s[o + " .cc-btn"].push("padding: " + a.padding), "transparent" != a.background && (s[o + " .cc-btn:hover, " + o + " .cc-btn:focus"] = ["background-color: " + (a.hover || v(a.background))]), c ? (c.text = c.text ? c.text : t.getContrast(c.background), c.border = c.border ? c.border : "transparent", s[o + " .cc-highlight .cc-btn:first-child"] = ["color: " + c.text, "border-color: " + c.border, "background-color: " + c.background]) : s[o + " .cc-highlight .cc-btn:first-child"] = ["color: " + r.text])); var l = document.createElement("style"); document.head.appendChild(l), e.customStyles[i] = { references: 1, element: l.sheet }; var u = -1; for (var h in s) s.hasOwnProperty(h) && l.sheet.insertRule(h + "{" + s[h].join(";") + "}", ++u) }
function v(e) { return e = t.normaliseHex(e), "000000" == e ? "#222" : t.getLuminance(e) }
function f(i) { if (t.isPlainObject(i)) { var n = t.hash(JSON.stringify(i)), o = e.customStyles[n]; if (o && !--o.references) { var s = o.element.ownerNode; s && s.parentNode && s.parentNode.removeChild(s), e.customStyles[n] = null } } }
function m(e, t) { for (var i = 0, n = e.length; i < n; ++i) { var o = e[i]; if (o instanceof RegExp && o.test(t) || "string" == typeof o && o.length && o === t) return !0 } return !1 }
function b() { var i = this.setStatus.bind(this), n = this.close.bind(this), o = this.options.dismissOnTimeout; "number" == typeof o && o >= 0 && (this.dismissTimeout = window.setTimeout(function() { i(e.status.dismiss), n(!0) }, Math.floor(o))); var s = this.options.dismissOnScroll; if ("number" == typeof s && s >= 0) { var r = function(t) { window.pageYOffset > Math.floor(s) && (i(e.status.dismiss), n(!0), window.removeEventListener("scroll", r), this.onWindowScroll = null) }; this.options.enabled && (this.onWindowScroll = r, window.addEventListener("scroll", r)) } var a = this.options.dismissOnWindowClick, c = this.options.ignoreClicksFrom; if (a) { var l = function(o) { for (var s = !1, r = o.path.length, a = c.length, u = 0; u < r; u++) if (!s) for (var h = 0; h < a; h++) s || (s = t.hasClass(o.path[u], c[h])); s || (i(e.status.dismiss), n(!0), window.removeEventListener("click", l), this.onWindowClick = null) }.bind(this); this.options.enabled && (this.onWindowClick = l, window.addEventListener("click", l)) } }
function g() { if ("info" != this.options.type && (this.options.revokable = !0), t.isMobile() && (this.options.animateRevokable = !1), this.options.revokable) { var e = a.call(this); this.options.animateRevokable && e.push("cc-animate"), this.customStyleSelector && e.push(this.customStyleSelector); var i = this.options.revokeBtn.replace("", e.join(" ")).replace("", this.options.content.policy); this.revokeBtn = u.call(this, i); var n = this.revokeBtn; if (this.options.animateRevokable) { var o = t.throttle(function(e) { var i = !1, o = 20, s = window.innerHeight - 20; t.hasClass(n, "cc-top") && e.clientY < o && (i = !0), t.hasClass(n, "cc-bottom") && e.clientY > s && (i = !0), i ? t.hasClass(n, "cc-active") || t.addClass(n, "cc-active") : t.hasClass(n, "cc-active") && t.removeClass(n, "cc-active") }, 200); this.onMouseMove = o, window.addEventListener("mousemove", o) } } } var y = { enabled: !0, container: null, cookie: { name: "cookieconsent_status", path: "/", domain: "", expiryDays: 365, secure: !1 }, onPopupOpen: function() {}, onPopupClose: function() {}, onInitialise: function(e) {}, onStatusChange: function(e, t) {}, onRevokeChoice: function() {}, onNoCookieLaw: function(e, t) {}, content: { header: "Cookies used on the website!", message: "This website uses cookies to ensure you get the best experience on our website.", dismiss: "Got it!", allow: "Allow cookies", deny: "Decline", link: "Learn more", href: "https://cookiesandyou.com", close: "❌", target: "_blank", policy: "Cookie Policy" }, elements: { header: '<span class="cc-header"></span> ', message: '<span id="cookieconsent:desc" class="cc-message"></span>', messagelink: '<span id="cookieconsent:desc" class="cc-message"> <a aria-label="learn more about cookies" role=button tabindex="0" class="cc-link" href="" rel="noopener noreferrer nofollow" target=""></a></span>', dismiss: '<a aria-label="dismiss cookie message" role=button tabindex="0" class="cc-btn cc-dismiss"></a>', allow: '<a aria-label="allow cookies" role=button tabindex="0" class="cc-btn cc-allow"></a>', deny: '<a aria-label="deny cookies" role=button tabindex="0" class="cc-btn cc-deny"></a>', link: '<a aria-label="learn more about cookies" role=button tabindex="0" class="cc-link" href="" rel="noopener noreferrer nofollow" target=""></a>', close: '<span aria-label="dismiss cookie message" role=button tabindex="0" class="cc-close"></span>' }, window: '<div role="dialog" aria-live="polite" aria-label="cookieconsent" aria-describedby="cookieconsent:desc" class="cc-window "><!--googleoff: all--><!--googleon: all--></div>', revokeBtn: '<div class="cc-revoke "></div>', compliance: { info: '<div class="cc-compliance"></div>', "opt-in": '<div class="cc-compliance cc-highlight"></div>', "opt-out": '<div class="cc-compliance cc-highlight"></div>' }, type: "info", layouts: { basic: "", "basic-close": "", "basic-header": "" }, layout: "basic", position: "bottom", theme: "block", "static": !1, palette: null, revokable: !1, animateRevokable: !0, showLink: !0, dismissOnScroll: !1, dismissOnTimeout: !1, dismissOnWindowClick: !1, ignoreClicksFrom: ["cc-revoke", "cc-btn"], autoOpen: !0, autoAttach: !0, whitelistPage: [], blacklistPage: [], overrideHTML: null }; return n.prototype.initialise = function(e) { this.options && this.destroy(), t.deepExtend(this.options = {}, y), t.isPlainObject(e) && t.deepExtend(this.options, e), r.call(this) && (this.options.enabled = !1), m(this.options.blacklistPage, location.pathname) && (this.options.enabled = !1), m(this.options.whitelistPage, location.pathname) && (this.options.enabled = !0); var i = this.options.window.replace("", c.call(this).join(" ")).replace("", l.call(this)), n = this.options.overrideHTML; if ("string" == typeof n && n.length && (i = n), this.options["static"]) { var o = u.call(this, '<div class="cc-grower">' + i + "</div>"); o.style.display = "", this.element = o.firstChild, this.element.style.display = "none", t.addClass(this.element, "cc-invisible") } else this.element = u.call(this, i); b.call(this), g.call(this), this.options.autoOpen && this.autoOpen() }, n.prototype.destroy = function() { this.onButtonClick && this.element && (this.element.removeEventListener("click", this.onButtonClick), this.onButtonClick = null), this.dismissTimeout && (clearTimeout(this.dismissTimeout), this.dismissTimeout = null), this.onWindowScroll && (window.removeEventListener("scroll", this.onWindowScroll), this.onWindowScroll = null), this.onWindowClick && (window.removeEventListener("click", this.onWindowClick), this.onWindowClick = null), this.onMouseMove && (window.removeEventListener("mousemove", this.onMouseMove), this.onMouseMove = null), this.element && this.element.parentNode && this.element.parentNode.removeChild(this.element), this.element = null, this.revokeBtn && this.revokeBtn.parentNode && this.revokeBtn.parentNode.removeChild(this.revokeBtn), this.revokeBtn = null, f(this.options.palette), this.options = null }, n.prototype.open = function(t) { if (this.element) return this.isOpen() || (e.hasTransition ? this.fadeIn() : this.element.style.display = "", this.options.revokable && this.toggleRevokeButton(), this.options.onPopupOpen.call(this)), this }, n.prototype.close = function(t) { if (this.element) return this.isOpen() && (e.hasTransition ? this.fadeOut() : this.element.style.display = "none", t && this.options.revokable && this.toggleRevokeButton(!0), this.options.onPopupClose.call(this)), this }, n.prototype.fadeIn = function() { var i = this.element; if (e.hasTransition && i && (this.afterTransition && s.call(this, i), t.hasClass(i, "cc-invisible"))) { if (i.style.display = "", this.options["static"]) { var n = this.element.clientHeight; this.element.parentNode.style.maxHeight = n + "px" } var r = 20; this.openingTimeout = setTimeout(o.bind(this, i), r) } }, n.prototype.fadeOut = function() { var i = this.element; e.hasTransition && i && (this.openingTimeout && (clearTimeout(this.openingTimeout), o.bind(this, i)), t.hasClass(i, "cc-invisible") || (this.options["static"] && (this.element.parentNode.style.maxHeight = ""), this.afterTransition = s.bind(this, i), i.addEventListener(e.transitionEnd, this.afterTransition), t.addClass(i, "cc-invisible"))) }, n.prototype.isOpen = function() { return this.element && "" == this.element.style.display && (!e.hasTransition || !t.hasClass(this.element, "cc-invisible")) }, n.prototype.toggleRevokeButton = function(e) { this.revokeBtn && (this.revokeBtn.style.display = e ? "" : "none") }, n.prototype.revokeChoice = function(e) { this.options.enabled = !0, this.clearStatus(), this.options.onRevokeChoice.call(this), e || this.autoOpen() }, n.prototype.hasAnswered = function(t) { return Object.keys(e.status).indexOf(this.getStatus()) >= 0 }, n.prototype.hasConsented = function(t) { var i = this.getStatus(); return i == e.status.allow || i == e.status.dismiss }, n.prototype.autoOpen = function(e) { !this.hasAnswered() && this.options.enabled ? this.open() : this.hasAnswered() && this.options.revokable && this.toggleRevokeButton(!0) }, n.prototype.setStatus = function(i) { var n = this.options.cookie, o = t.getCookie(n.name), s = Object.keys(e.status).indexOf(o) >= 0; Object.keys(e.status).indexOf(i) >= 0 ? (t.setCookie(n.name, i, n.expiryDays, n.domain, n.path, n.secure), this.options.onStatusChange.call(this, i, s)) : this.clearStatus() }, n.prototype.getStatus = function() { return t.getCookie(this.options.cookie.name) }, n.prototype.clearStatus = function() { var e = this.options.cookie; t.setCookie(e.name, "", -1, e.domain, e.path) }, n }(), e.Location = function() { function e(e) { t.deepExtend(this.options = {}, s), t.isPlainObject(e) && t.deepExtend(this.options, e), this.currentServiceIndex = -1 }
function i(e, t, i) { var n, o = document.createElement("script"); o.type = "text/" + (e.type || "javascript"), o.src = e.src || e, o.async = !1, o.onreadystatechange = o.onload = function() { var e = o.readyState; clearTimeout(n), t.done || e && !/loaded|complete/.test(e) || (t.done = !0, t(), o.onreadystatechange = o.onload = null) }, document.body.appendChild(o), n = setTimeout(function() { t.done = !0, t(), o.onreadystatechange = o.onload = null }, i) }
function n(e, t, i, n, o) { var s = new(window.XMLHttpRequest || window.ActiveXObject)("MSXML2.XMLHTTP.3.0"); if (s.open(n ? "POST" : "GET", e, 1), s.setRequestHeader("Content-type", "application/x-www-form-urlencoded"), Array.isArray(o)) for (var r = 0, a = o.length; r < a; ++r) { var c = o[r].split(":", 2); s.setRequestHeader(c[0].replace(/^\s+|\s+$/g, ""), c[1].replace(/^\s+|\s+$/g, "")) } "function" == typeof t && (s.onreadystatechange = function() { s.readyState > 3 && t(s) }), s.send(n) }
function o(e) { return new Error("Error [" + (e.code || "UNKNOWN") + "]: " + e.error) } var s = { timeout: 5e3, services: ["ipinfo"], serviceDefinitions: { ipinfo: function() { return { url: "//ipinfo.io", headers: ["Accept: application/json"], callback: function(e, t) { try { var i = JSON.parse(t); return i.error ? o(i) : { code: i.country } } catch (n) { return o({ error: "Invalid response (" + n + ")" }) } } } }, ipinfodb: function(e) { return { url: "//api.ipinfodb.com/v3/ip-country/?key={api_key}&format=json&callback={callback}", isScript: !0, callback: function(e, t) { try { var i = JSON.parse(t); return "ERROR" == i.statusCode ? o({ error: i.statusMessage }) : { code: i.countryCode } } catch (n) { return o({ error: "Invalid response (" + n + ")" }) } } } }, maxmind: function() { return { url: "//js.maxmind.com/js/apis/geoip2/v2.1/geoip2.js", isScript: !0, callback: function(e) { return window.geoip2 ? void geoip2.country(function(t) { try { e({ code: t.country.iso_code }) } catch (i) { e(o(i)) } }, function(t) { e(o(t)) }) : void e(new Error("Unexpected response format. The downloaded script should have exported `geoip2` to the global scope")) } } } } }; return e.prototype.getNextService = function() { var e; do e = this.getServiceByIdx(++this.currentServiceIndex); while (this.currentServiceIndex < this.options.services.length && !e); return e }, e.prototype.getServiceByIdx = function(e) { var i = this.options.services[e]; if ("function" == typeof i) { var n = i(); return n.name && t.deepExtend(n, this.options.serviceDefinitions[n.name](n)), n } return "string" == typeof i ? this.options.serviceDefinitions[i]() : t.isPlainObject(i) ? this.options.serviceDefinitions[i.name](i) : null }, e.prototype.locate = function(e, t) { var i = this.getNextService(); return i ? (this.callbackComplete = e, this.callbackError = t, void this.runService(i, this.runNextServiceOnError.bind(this))) : void t(new Error("No services to run")) }, e.prototype.setupUrl = function(e) { var t = this.getCurrentServiceOpts(); return e.url.replace(/\{(.*?)\}/g, function(i, n) { if ("callback" === n) { var o = "callback" + Date.now(); return window[o] = function(t) { e.__JSONP_DATA = JSON.stringify(t) }, o } if (n in t.interpolateUrl) return t.interpolateUrl[n] }) }, e.prototype.runService = function(e, t) { var o = this; if (e && e.url && e.callback) { var s = e.isScript ? i : n, r = this.setupUrl(e); s(r, function(i) { var n = i ? i.responseText : ""; e.__JSONP_DATA && (n = e.__JSONP_DATA, delete e.__JSONP_DATA), o.runServiceCallback.call(o, t, e, n) }, this.options.timeout, e.data, e.headers) } }, e.prototype.runServiceCallback = function(e, t, i) { var n = this, o = function(t) { s || n.onServiceResult.call(n, e, t) }, s = t.callback(o, i); s && this.onServiceResult.call(this, e, s) }, e.prototype.onServiceResult = function(e, t) { t instanceof Error || t && t.error ? e.call(this, t, null) : e.call(this, null, t) }, e.prototype.runNextServiceOnError = function(e, t) { if (e) { this.logError(e); var i = this.getNextService(); i ? this.runService(i, this.runNextServiceOnError.bind(this)) : this.completeService.call(this, this.callbackError, new Error("All services failed")) } else this.completeService.call(this, this.callbackComplete, t) }, e.prototype.getCurrentServiceOpts = function() { var e = this.options.services[this.currentServiceIndex]; return "string" == typeof e ? { name: e } : "function" == typeof e ? e() : t.isPlainObject(e) ? e : {} }, e.prototype.completeService = function(e, t) { this.currentServiceIndex = -1, e && e(t) }, e.prototype.logError = function(e) { var t = this.currentServiceIndex, i = this.getServiceByIdx(t); console.warn("The service[" + t + "] (" + i.url + ") responded with the following error", e) }, e }(), e.Law = function() { function e(e) { this.initialise.apply(this, arguments) } var i = { regionalLaw: !0, hasLaw: ["AT", "BE", "BG", "HR", "CZ", "CY", "DK", "EE", "FI", "FR", "DE", "EL", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "SK", "ES", "SE", "GB", "UK", "GR", "EU"], revokable: ["HR", "CY", "DK", "EE", "FR", "DE", "LV", "LT", "NL", "PT", "ES"], explicitAction: ["HR", "IT", "ES"] }; return e.prototype.initialise = function(e) { t.deepExtend(this.options = {}, i), t.isPlainObject(e) && t.deepExtend(this.options, e) }, e.prototype.get = function(e) { var t = this.options; return { hasLaw: t.hasLaw.indexOf(e) >= 0, revokable: t.revokable.indexOf(e) >= 0, explicitAction: t.explicitAction.indexOf(e) >= 0 } }, e.prototype.applyLaw = function(e, t) { var i = this.get(t); return i.hasLaw || (e.enabled = !1, "function" == typeof e.onNoCookieLaw && e.onNoCookieLaw(t, i)), this.options.regionalLaw && (i.revokable && (e.revokable = !0), i.explicitAction && (e.dismissOnScroll = !1, e.dismissOnTimeout = !1)), e }, e }(), e.initialise = function(i, n, o) { var s = new e.Law(i.law); n || (n = function() {}), o || (o = function() {}); var r = Object.keys(e.status), a = t.getCookie("cookieconsent_status"), c = r.indexOf(a) >= 0; return c ? void n(new e.Popup(i)) : void e.getCountryCode(i, function(t) { delete i.law, delete i.location, t.code && (i = s.applyLaw(i, t.code)), n(new e.Popup(i)) }, function(t) { delete i.law, delete i.location, o(t, new e.Popup(i)) }) }, e.getCountryCode = function(t, i, n) { if (t.law && t.law.countryCode) return void i({ code: t.law.countryCode }); if (t.location) { var o = new e.Location(t.location); return void o.locate(function(e) { i(e || {}) }, n) } i({}) }, e.utils = t, e.hasInitialised = !0, window.cookieconsent = e } }(window.cookieconsent || {});
Include the above
cookieconsent
library before your version of the Clarity script.Below you can see a reference to the
cookieconsent
library, followed by my Clarity script:_layouts/base.html fragment<script src="https://cdn.jsdelivr.net/npm/cookieconsent@3.1.0/build/cookieconsent.min.js" data-cfasync="false"></script> <script src="/assets/js/cookie-consent-script.js"></script> <script> (function (c, l, a, r, i, t, y) { c[a] = c[a] || function () { (c[a].q = c[a].q || []).push(arguments) }; t = l.createElement(r); t.async = 1; t.src = "https://www.clarity.ms/tag/" + i + "?ref=bwt"; y = l.getElementsByTagName(r)[0]; y.parentNode.insertBefore(t, y); })(window, document, "clarity", "script", "secretstuffhere"); </script>
- Custom JavaScript to position the popup created by
cookieconsent
Including the cookieconsent library into your website<script src="https://cdn.jsdelivr.net/npm/cookieconsent@3/build/cookieconsent.min.js" data-cfasync="false"> </script>
/* Consent Banner and Clarity Consent API Integration */
window.addEventListener('load', function () {
if (window.cookieconsent && window.cookieconsent.initialise) {
window.cookieconsent.initialise({
"palette": {
"popup": {
"background": "#134376", // Background color of the consent popup
"text": "#9DDCFF" // Text color within the consent popup
},
"button": {
"background": "#9DDCFF", // Background color of the dismiss button
"text": "#134376" // Text color of the dismiss button
}
},
"position": "bottom-right",
"theme": "classic",
"type": "opt-in",
"content": {
"message": "This website uses cookies to analyze performance and provide basic functionality.",
"dismiss": "Decline",
"allow": "Accept",
"link": "Learn more",
"href": "/privacy-policy.html"
},
onInitialise: function (status) {
if (status === "allow") {
window.clarity('consent', true);
} else {
window.clarity('consent', false);
}
},
onStatusChange: function (status) {
if (status === "allow") {
window.clarity('consent', true);
} else {
window.clarity('consent', false);
}
}
});
}
});
The privacy policy must discuss Clarity’s analytics cookies.
A standardized stylesheet from cookieconsent
exists.
You can also use this nice form.
Use it in an HTML document like this:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cookieconsent@3.1.0/build/cookieconsent.min.css" />
I wish I knew how to make the popup appear on demand. That would allow me to fuss with the styling. I do not use a VPN and I do not want to get one because I believe VPNs decrease security. Currently I just deploy a new version of the website after making a cosmetic change, then I lurk on the Clarity site until a visitor from the EU comes along so I can watch the video of their visit. This is the only way I know to see how the popup looks. Hopefully someone will educate me soon.
The above CSS is compressed; when formatted you can see how large it is. Surely this is much more than is required to style a simple Yes/No popup.
.cc-window { opacity: 1; transition: opacity 1s ease } .cc-window.cc-invisible { opacity: 0 } .cc-animate.cc-revoke { transition: transform 1s ease } .cc-animate.cc-revoke.cc-top { transform: translateY(-2em) } .cc-animate.cc-revoke.cc-bottom { transform: translateY(2em) } .cc-animate.cc-revoke.cc-active.cc-bottom, .cc-animate.cc-revoke.cc-active.cc-top, .cc-revoke:hover { transform: translateY(0) } .cc-grower { max-height: 0; overflow: hidden; transition: max-height 1s } .cc-link, .cc-revoke:hover { text-decoration: underline } .cc-revoke, .cc-window { position: fixed; overflow: hidden; box-sizing: border-box; font-family: Helvetica, Calibri, Arial, sans-serif; font-size: 16px; line-height: 1.5em; display: -ms-flexbox; display: flex; -ms-flex-wrap: nowrap; flex-wrap: nowrap; z-index: 9999 } .cc-window.cc-static { position: static } .cc-window.cc-floating { padding: 2em; max-width: 24em; -ms-flex-direction: column; flex-direction: column } .cc-window.cc-banner { padding: 1em 1.8em; width: 100%; -ms-flex-direction: row; flex-direction: row } .cc-revoke { padding: .5em } .cc-header { font-size: 18px; font-weight: 700 } .cc-btn, .cc-close, .cc-link, .cc-revoke { cursor: pointer } .cc-link { opacity: .8; display: inline-block; padding: .2em } .cc-link:hover { opacity: 1 } .cc-link:active, .cc-link:visited { color: initial } .cc-btn { display: block; padding: .4em .8em; font-size: .9em; font-weight: 700; border-width: 2px; border-style: solid; text-align: center; white-space: nowrap } .cc-highlight .cc-btn:first-child { background-color: transparent; border-color: transparent } .cc-highlight .cc-btn:first-child:focus, .cc-highlight .cc-btn:first-child:hover { background-color: transparent; text-decoration: underline } .cc-close { display: block; position: absolute; top: .5em; right: .5em; font-size: 1.6em; opacity: .9; line-height: .75 } .cc-close:focus, .cc-close:hover { opacity: 1 } .cc-revoke.cc-top { top: 0; left: 3em; border-bottom-left-radius: .5em; border-bottom-right-radius: .5em } .cc-revoke.cc-bottom { bottom: 0; left: 3em; border-top-left-radius: .5em; border-top-right-radius: .5em } .cc-revoke.cc-left { left: 3em; right: unset } .cc-revoke.cc-right { right: 3em; left: unset } .cc-top { top: 1em } .cc-left { left: 1em } .cc-right { right: 1em } .cc-bottom { bottom: 1em } .cc-floating>.cc-link { margin-bottom: 1em } .cc-floating .cc-message { display: block; margin-bottom: 1em } .cc-window.cc-floating .cc-compliance { -ms-flex: 1 0 auto; flex: 1 0 auto } .cc-window.cc-banner { -ms-flex-align: center; align-items: center } .cc-banner.cc-top { left: 0; right: 0; top: 0 } .cc-banner.cc-bottom { left: 0; right: 0; bottom: 0 } .cc-banner .cc-message { display: block; -ms-flex: 1 1 auto; flex: 1 1 auto; max-width: 100%; margin-right: 1em } .cc-compliance { display: -ms-flexbox; display: flex; -ms-flex-align: center; align-items: center; -ms-flex-line-pack: justify; align-content: space-between } .cc-floating .cc-compliance>.cc-btn { -ms-flex: 1; flex: 1 } .cc-btn+.cc-btn { margin-left: .5em } @media print { .cc-revoke, .cc-window { display: none } } @media screen and (max-width:900px) { .cc-btn { white-space: normal } } @media screen and (max-width:414px) and (orientation:portrait), screen and (max-width:736px) and (orientation:landscape) { .cc-window.cc-top { top: 0 } .cc-window.cc-bottom { bottom: 0 } .cc-window.cc-banner, .cc-window.cc-floating, .cc-window.cc-left, .cc-window.cc-right { left: 0; right: 0 } .cc-window.cc-banner { -ms-flex-direction: column; flex-direction: column } .cc-window.cc-banner .cc-compliance { -ms-flex: 1 1 auto; flex: 1 1 auto } .cc-window.cc-floating { max-width: none } .cc-window .cc-message { margin-bottom: 1em } .cc-window.cc-banner { -ms-flex-align: unset; align-items: unset } .cc-window.cc-banner .cc-message { margin-right: 0 } } .cc-floating.cc-theme-classic { padding: 1.2em; border-radius: 5px } .cc-floating.cc-type-info.cc-theme-classic .cc-compliance { text-align: center; display: inline; -ms-flex: none; flex: none } .cc-theme-classic .cc-btn { border-radius: 5px } .cc-theme-classic .cc-btn:last-child { min-width: 140px } .cc-floating.cc-type-info.cc-theme-classic .cc-btn { display: inline-block } .cc-theme-edgeless.cc-window { padding: 0 } .cc-floating.cc-theme-edgeless .cc-message { margin: 2em 2em 1.5em } .cc-banner.cc-theme-edgeless .cc-btn { margin: 0; padding: .8em 1.8em; height: 100% } .cc-banner.cc-theme-edgeless .cc-message { margin-left: 1em } .cc-floating.cc-theme-edgeless .cc-btn+.cc-btn { margin-left: 0 }
Test the Setup
- Deploy changes to each site.
- Visit each domain to simulate an EEA/UK location. You can use a VPN or a browser extension.
- Interact with the banner: Click Accept or Decline and verify behavior.
-
In browser dev tools (F12 / Application / Cookies),
confirm no Clarity cookies (e.g.,
_clck
,_clsk
) are set when consent is declined. -
In the Clarity dashboard (
clarity.microsoft.com
), go to Settings / Consent / Check Consent Mode status (it will show green if it is working). See Microsoft’s Consent verification guide. - Test on multiple browsers (e.g., Chrome, Firefox) to ensure compatibility.
Monitor and Maintain
- Periodically check Clarity’s dashboard for consent signal errors.
- If issues arise, email clarityms@microsoft.com with your project IDs.
- Re-test before October 31, 2025, to ensure compliance.
Notes
-
Customization: Adjust the banner’s
palette
,position
, orcontent
in the script to match your site’s design (see CookieConsent docs). - Limitations: This is a basic opt-in banner. For advanced features (e.g., category-based consent), you’d need a more complex script or a CMP like CookieYes (not free). This setup meets Clarity’s requirements for GDPR compliance.
- CMS Users: If your sites use a CMS (e.g., WordPress), you can use a free plugin like Complianz (free tier) to manage the banner and call Clarity’s API.
-
Alternative: If you want a fully custom banner without CookieConsent,
you’d need to code a consent UI and store user choices
(e.g., in
localStorage
), then callwindow.clarity('consent', true/false)
based on user input. This is more complex and not recommended unless you’re experienced with JavaScript. See Clarity Consent API docs.
Looking Ahead
I would very much like to receive a continuous data feed of the above AI-generated per-visit summary, in real time as it becomes available. An email stream would be a quick way to start. A streaming api for data would be a logical next step.
Perhaps the Clarity API might be a place to start.