How the tracker handles consent: what an unanswered, accepted or declined choice does, waiting for consent, connecting your cookie banner, and DNT/GPC.
The Web Analytics module ships with privacy defaults that make GDPR / CCPA / DACH / CalOPPA compliance achievable without piling on extra middleware. Browser opt-out signals and declined consent are honored in the browser and again on the server, IP addresses never reach storage in plaintext, and retention is configurable per tenant or per site. For sites that need consent before anything is stored, the tracker can wait for consent from your cookie banner.
Whether your setup is lawful for your visitors is your decision as the site owner; this page describes what the tracker does so you can make it.
The tracker reads navigator.doNotTrack and navigator.globalPrivacyControl on every event and includes both in the event's consent block:
consent: { analytics: null, marketing: null, dnt: true, gpc: false }
When the Site's respectDnt flag is true (the default), the server-side applySiteGuards step filters out any event whose consent.dnt === true or consent.gpc === true before it's queued for BigQuery insert. The /collect response reports those events under filtered.
The client also short-circuits earlier: when respectDnt is true AND DNT/GPC is detected, the tracker doesn't even enqueue the event — saving the network round-trip. The server-side filter exists as a defense-in-depth in case the client config was wrong or stale.
To opt out of respecting DNT (uncommon, regulated industries): set respectDnt: false on the Site. The tracker will then enqueue events even from DNT-signaling browsers. The Tenant is responsible for whatever legal basis allows this.
Every event carries a consent block, and each of its two choices has three possible values:
| Value | Means | What happens to the event |
|---|---|---|
null |
Never asked. No banner on the page reported a choice. | Recorded. On a site without a cookie banner this is the normal state, and whether that is lawful for your visitors is your responsibility as the site owner. |
true |
The visitor accepted that category in your banner. | Recorded. |
false |
The visitor declined it. | For analytics: false, not recorded: the tracker stops sending, deletes its own cookie and stored ids, and the server drops any declined event that still arrives. For marketing: false, the event is recorded but ad-click ids (gclid, gbraid, wbraid) are kept out of it and out of the browser. |
Your banner reports the choice with one call:
karma('consent', { analytics: true, marketing: false });
Call it on every page load once the visitor has a stored choice, and again whenever they change it. The call can be made before the tracker has finished loading; the snippet's stub queues it.
By default the tracker starts on page load and treats "never asked" as allowed. If your visitors include people in places where analytics needs consent before anything is stored (the EU/EEA, the UK, Switzerland, Québec), turn on Wait for consent: open Data → Web Analytics, pick the site, then Configure → Settings. It is off for every site until you turn it on.
With it on:
karma('consent', { analytics: true }).Only turn this on if your banner calls
karma('consent', …). Without that call every visit looks like "no choice yet" and nothing is recorded. The Settings page warns you when a site that waits has received no events in the last 24 hours.
If Karmaflow.ai cannot load your site's settings when a browser asks for the tracker (a brief outage), the tracker is served in waiting mode for that request rather than risk recording a visitor who has not consented.
Whatever banner you use, the job is the same: when a choice is known, call karma('consent', …) with the analytics and marketing answers. A few common setups:
Google Tag Manager. If your banner runs through Tag Manager, add a Custom HTML tag that fires on the banner's consent event and passes the banner's answers, read from the Data Layer Variables your banner provides:
<script>
karma('consent', { analytics: {{Analytics consent}}, marketing: {{Marketing consent}} });
</script>
{{Analytics consent}} and {{Marketing consent}} stand for your own variables; they must evaluate to true or false.
OneTrust
function OptanonWrapper() {
var groups = window.OnetrustActiveGroups || '';
karma('consent', {
analytics: groups.indexOf('C0002') > -1, // Performance
marketing: groups.indexOf('C0004') > -1, // Targeting
});
}
Cookiebot
function onCookiebotChange() {
karma('consent', { analytics: Cookiebot.consent.statistics, marketing: Cookiebot.consent.marketing });
}
window.addEventListener('CookiebotOnAccept', onCookiebotChange);
window.addEventListener('CookiebotOnDecline', onCookiebotChange);
CookieYes and others. Use the banner's "consent changed" event and pass its analytics and marketing answers the same way. Check your vendor's documentation for the exact event and category names; the ones above are the vendors' defaults and your configuration may rename them.
A site with no banner, where visitors can opt out. Leave Wait for consent off, and call karma('consent', { analytics: false, marketing: false }) from your opt-out control. The tracker stops and deletes what it stored.
These are separate from the banner: they are signals the visitor's own browser sends, and they are covered in the section above. With Respect Do Not Track on (the default), a browser sending either signal is not recorded whatever the banner says.
IPs are hashed before storage:
ip_hash = sha256(ip + tenant_salt).slice(0, 32);
process.env.ANALYTICS_IP_HASH_SALT by default, falling back to the tenant ID if the env var isn't set. Setting a tenant-specific salt is best practice — it makes cross-tenant correlation by IP impossible even if our DB is breached.Geo enrichment (country / region / city columns in BQ) is derived from the IP before hashing, using MaxMind's GeoLite2 database via the geoip-lite package — entirely in-process, no external API calls, no per-event cost. The raw IP is discarded immediately after the geo lookup and the hash are computed. By default only country + region are recorded; city-level geo is opt-in per Site (Site.collectCityGeo = true) since some jurisdictions consider city-level location PII. Private and loopback IPs (RFC1918, IPv6 ULA, link-local) are not resolved — they yield all-null geo so dev / staging / internal-proxy traffic isn't mis-attributed.
The GeoLite2 data is bundled with the geoip-lite package and updates on npm install. Redeploy roughly monthly to keep country / region accuracy current.
To delete all events from a single IP retroactively (e.g. on a deletion request), compute the same hash with the tenant salt and run:
DELETE FROM events
WHERE tenant_id = @tenant
AND ip_hash = @hash
Right-to-be-forgotten requests for a known user_id are simpler — both events.user_id and identity_map.user_id are deletable directly.
Events are kept indefinitely until you set a window and turn removal on. Web Analytics follows the same rule as Transcript Retention: nothing is ever removed because of a default, a blank window means kept indefinitely, and automatic removal is a separate switch a tenant admin turns on with a typed confirmation.
| Layer | Where | Applies to |
|---|---|---|
| Account default | Settings (the gear icon) → Access & security → Transcript Retention, the Web analytics events card, Default window (days) — tenant admins only | every site without its own window |
| Site window | the site's Configure → Settings → Retention → Keep events for (days) (open Data → Web Analytics and pick the site) | that site only; overrides the account default |
The site's Settings page shows the window that actually reaches it — 30 days (this site's own window), 760 days (account default) or kept indefinitely — and whether automatic removal is on for the workspace.
On the Web analytics events card, tick Remove automatically, type the confirmation phrase and click Save web analytics retention. From the next daily run, every event past its window is deleted. Preview these settings counts what the settings on screen would remove right now, per site, without saving or removing anything. While removal is off the windows are inert: nothing is deleted whatever they say, and both pages say so.
Once a day, for each site that has a window:
Removal is permanent; there is no Recently Deleted for events. The Last automatic run panel on the card shows when the run happened and how many rows it removed, and every save leaves a row on the Audit Trail. Rows that reached Karmaflow.ai within the last two hours are left for the next run whatever their timestamp, because the warehouse cannot delete rows it is still receiving.
visitor_id is a random id kept in a first-party cookie (_k_v, 365 days) and in localStorage on your own site. The session id (_k_s) is in localStorage only; a first click from a Google ad also stores its click id in a first-party _k_click cookie (90 days). Nothing is shared across sites, so the same person on two different Karmaflow.ai customer sites has two unrelated ids. With Wait for consent on, none of these is written until the visitor accepts analytics.user_agent and device_type, but don't probe canvas, WebGL, audio context, fonts, or any of the other surfaces fingerprinting libraries use.properties are sent verbatim. If you put an email in properties.email, we'll store it. Don't put PII in event properties unless you want it stored.Most privacy frameworks require some disclosure in your privacy policy. A typical paragraph:
We use Karmaflow.ai Web Analytics, a first-party analytics platform, to understand how visitors use our site. We collect page views, clicks, form submissions, scroll depth, and performance metrics. IP addresses are hashed before storage. We honor Do Not Track and Global Privacy Control browser signals. You can configure your preferences via the cookie banner above.
If you've turned respectDnt off, document that explicitly; many jurisdictions require disclosure of opt-out signals not being honored.