The Web Analytics module ships with privacy defaults that make GDPR / CCPA / DACH / CalOPPA compliance achievable without piling on extra middleware. Tracking opt-outs are honored at three layers (client, network, server), IP addresses never reach storage in plaintext, and retention is configurable per tenant or per site.
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.
For sites that use a cookie banner, the tracker provides an explicit consent API:
karma('consent', { analytics: true, marketing: false });
This updates the tracker's internal consent state. Every subsequent event includes the current values in its consent block. The server doesn't enforce anything on consent.analytics / consent.marketing — those are advisory fields you can filter on yourself in queries:
-- Count events with positive analytics consent
SELECT COUNT(*) FROM events
WHERE JSON_VALUE(consent_state, '$.analytics') = 'true'
The reason consent.analytics and consent.marketing aren't server-enforced: every site has different legal interpretations of what counts as "analytics" consent vs "essential cookies", and some sites need to record the un-consented event for legal-basis tracking ("we received this signal, we noted the lack of consent, we didn't act on it"). Recording the state per-event preserves that flexibility.
DNT/GPC are server-enforced because those are user-asserted preferences from the browser, not site-asserted ones.
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.
Default retention is 25 months (Tenant.analyticsRetentionDays = 760), which is longer than GA4's 14-month cap and intentionally chosen to be long enough for year-over-year analyses.
Set Tenant.analyticsRetentionDays to any number of days, or to null for "unlimited" (paid tier).
Site.analyticsRetentionDays overrides the tenant default for one site. Useful for sites with stricter privacy requirements than the rest of the tenant's portfolio.
A nightly Cloud Run Job (analyticsRetentionJob.js, triggered via the ticks system) runs:
DELETE FROM events
WHERE tenant_id = @tenant
AND site_id = @site
AND event_ts < TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @days DAY)
per site whose retention is finite. The same is done for identity_map and any rollup collections.
The job logs deletion counts to the platform log so you have an audit trail of how much was removed.
When a Tenant is being offboarded, deletion is a single one-line query against the shared dataset:
DELETE FROM events WHERE tenant_id = @tenant;
DELETE FROM identity_map WHERE tenant_id = @tenant;
DELETE FROM events_dlq WHERE tenant_id = @tenant;
Plus dropping the tenant's Mongo database. The fleet bootstrap script has a --delete-tenant flag that runs all of this in order; never run those DELETEs by hand from a SQL console because forgetting the WHERE tenant_id = ... would wipe everyone.
visitor_id is a UUID in localStorage scoped to the snippet's origin. With a first-party CNAME it's scoped to the customer's own domain; without one, it's scoped to omni.karmaflow.ai (which means visitors who visit two different customer sites get two different visitor_ids — there's no cross-site tracking).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.// Inside your OneTrust group toggle handler:
function onOneTrustChange() {
const analyticsConsent = OnetrustActiveGroups.includes('C0002'); // Performance
const marketingConsent = OnetrustActiveGroups.includes('C0004'); // Marketing
karma('consent', { analytics: analyticsConsent, marketing: marketingConsent });
}
// On any consent-changed event:
karma('consent', {
analytics: consentApi.has('analytics'),
marketing: consentApi.has('marketing'),
});
// On script load, default to opted-in:
karma('consent', { analytics: true, marketing: true });
// If user later opts out:
document.querySelector('#opt-out').addEventListener('click', () => {
karma('consent', { analytics: false, marketing: false });
});
Most privacy frameworks require some disclosure in your privacy policy. A typical paragraph:
We use KarmaFlow 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.