# Privacy, Consent & Retention

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.

## Do Not Track (DNT) and Global Privacy Control (GPC)

The tracker reads `navigator.doNotTrack` and `navigator.globalPrivacyControl` on every event and includes both in the event's `consent` block:

```js
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.

## Consent

### What the tracker records about consent

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:

```js
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.

### Wait for consent

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:

- The tracker sets **no cookie and no stored id, and sends nothing** until your banner calls `karma('consent', { analytics: true })`.
- When that call arrives, it records a page view for the page the visitor is on and runs normally from then on. Clicks and pages from **before** the choice are not recorded later: nothing is held back and replayed.
- A visitor who declines, or never chooses, is not recorded at all.
- The setting does not know where a visitor is: the tracker waits for everyone. Most consent tools grant analytics automatically outside the regions that require opt-in; if yours does, those visitors are recorded as soon as the page loads.
- A change reaches visitors' browsers within about 10 minutes.

> **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.

### Connecting your cookie banner

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:

```html
<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**

```js
function OptanonWrapper() {
  var groups = window.OnetrustActiveGroups || '';
  karma('consent', {
    analytics: groups.indexOf('C0002') > -1, // Performance
    marketing: groups.indexOf('C0004') > -1, // Targeting
  });
}
```

**Cookiebot**

```js
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.

### Do Not Track and Global Privacy Control

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.

## IP Addresses

IPs are hashed before storage:

```js
ip_hash = sha256(ip + tenant_salt).slice(0, 32);
```

- The salt is `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.
- Hash output is truncated to 32 hex characters. Collisions are statistically negligible at our row volumes.
- The raw IP is never logged, never written to BQ, and never returned by any API. It exists only in the in-memory request object during the lifetime of one HTTP request.

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:

```sql
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.

## Retention

**Events are kept indefinitely until you set a window and turn removal on.** Web Analytics follows the same rule as [Transcript Retention](/help/workspace-administration/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.

### Where the windows live

| 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.

### Turning removal on

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.

### What a run removes

Once a day, for each site that has a window:

- the site's events older than the window;
- the failed-insert (dead-letter) rows and the visitor↔user identity rows older than the window;
- the per-day rollups the Overview charts read from, for days past the 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.

## What's *Not* Tracked

- **No third-party cookies.** The `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.
- **No browser fingerprinting.** We record `user_agent` and `device_type`, but don't probe canvas, WebGL, audio context, fonts, or any of the other surfaces fingerprinting libraries use.
- **No form values.** Only structure (field names, counts, has-password flag). See the **Auto-Collected Events** doc.
- **No PII unless you put it there.** Custom event `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.

## What the Tenant Has to Display

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.
