Privacy first, by design: what InsightClickIQ never collects

The privacy question about a click analytics tool is not whether it has a settings page. It is what happens on the day nobody remembered to configure it. A snippet that records every keystroke until someone adds a masking rule will, sooner or later, record a password, a card number or a diagnosis typed into a free-text box. The only reliable answer is a product whose defaults are already the safe ones, and whose unsafe options have to be chosen on purpose.

This post walks through InsightClickIQ layer by layer — the browser, ingest, storage, and the AI — and says at each layer what is stopped, what is masked, and what is kept. It finishes with the things the product does not do for you, because a privacy review that only lists the controls is a sales document.

Where personal data is stopped, layer by layer Four layers from the visitor's browser to the AI. In the browser, field values, passwords, card fields and blocked regions never leave, private text is masked and consent can stop everything. At ingest, the write key and allowed origins scope what is accepted and the IP is used for geo and dropped. In storage, row-level security pins every row to an organisation, retention deletes on a schedule and a person can be erased. The AI sees computed numbers and event timelines with masked text, never raw values, and never writes SQL. 1 · THE BROWSER 2 · INGEST 3 · STORAGE 4 · CONSOLE AND AI NEVER LEAVES field values, any input passwords, cc-* fields [data-clickiq-block] everything, after consent false MASKED [data-private] text → **** replay inputs → •••• SENT length bucket, timing identify(guid), not email first-party ids, 1-year DNT / GPC honoured when respect_dnt is on REJECTED unknown write key → 401 other origins → 403 > 1 MB, > 500 events → 413 USED, THEN DROPPED client IP → country, city User-Agent → browser, OS KEY FACTS write key is public by design: it can add events, never read them; rotate any time native apps send no Origin; the key scopes the project ENFORCED RLS on organization app role is not superuser ClickHouse: org predicate replays in your bucket DELETED past retention_days (395) replay objects with them one person, on request daily round at 03:00 local; whole month partitions dropped when no project still keeps a row in them THE MODEL SEES computed numbers event timelines, masked catalog: names, top values NEVER writes or runs SQL sees replay pixels crosses organisations your model provider: Bedrock, Azure OpenAI, Vertex, OCI, compatible — or none (CLICKIQ_AI=false)
Four layers, four different kinds of protection. Most of what matters happens in the first column: data that never leaves the visitor’s browser cannot be leaked, subpoenaed or mis-retained.

1. In the browser: values never leave

The browser SDK never reads the value of an input, textarea or select. That is not a masking rule applied on the way out; the code does not read the value in the first place. A submit button reports its label, because the label is page text, not something the visitor typed. Clicks carry the element’s tag, its visible text and a CSS selector, so “Apply coupon” is recorded as a click on button#apply-coupon, and the coupon code typed beside it is not recorded at all.

Form analytics is the place where this is easiest to get wrong, because the whole point is to measure fields. InsightClickIQ measures them without their contents. Here is a real form_field event from the Northwind checkout, exactly as stored:

{ "type": "form_field",
  "props": { "form": "#checkout-form", "form_name": "checkout",
             "field": "city", "field_type": "text", "order": 4,
             "focus_ms": 1017, "visits": 1, "changed": true,
             "refilled": false, "empty": false,
             "length_bucket": "1-10" },
  "el": { "tag": "form", "id": "checkout-form", "selector": "form#checkout-form" } }

The field’s name, its position, how long it held focus, whether it was changed or re-filled, whether it was left empty, and a length bucket — 0, 1–10, 11–50 or 50+. Not the city. Password fields, anything with autocomplete="cc-*" or a card-like name, hidden, submit and file inputs, and anything inside a blocked region are skipped entirely, and the SDK stops at 60 fields per form.

Forms · checkout · Fields
The Fields table of the Northwind checkout form: email 40 sessions, 2.1 s, 23% refilled, flagged Slow; first, last, address, city (24% left empty, 42% of drop-offs, flagged Drop-off and Left empty), zip, and shipping (15% errors, flagged Error)
Everything a form analyst needs, nothing a visitor typed. City is where 42% of abandoners stopped and 24% left it empty — worked out from the length bucket and the empty flag, never from a single address.

Three attributes give you finer control without a release of the SDK:

Attribute (or project setting)Effect on eventsEffect on replay
data-private (mask_text_selector)Element text is sent as ****Text masked
data-clickiq-block (block_selector)No click, rage, dead or form events from insideNot recorded at all
mask_all_inputs (default on)— (values are never read anyway)Every input shows as dots
respect_dntHonours Do Not Track and Global Privacy ControlSame
clickiq("consent", false)Stops; clears the queue, ids and cookie; rememberedStops and clears the recording

Replay follows the same rules. Every input is masked by default, so a recording of a checkout shows that a field was filled and how long it took, but never what went in it.

Session replay · checkout, masked
A replay frame of the Northwind checkout: email, first name, last name, address, city and postcode fields all showing only dots; the Same-day (beta) delivery option selected; the cart summary with Cascade Down Parka $349 visible
The page text is there; the visitor’s is not. Product, price and delivery option are the store’s own content and show normally. Every field the visitor typed into is dots. This is the default, with no configuration.

2. Identify people without naming them

Analytics needs to know that two sessions are the same person. It does not need to know who the person is. clickiq("identify", id, traits) takes any stable id, and our advice — written into the mobile SDK guides — is an account guid: never an email or a name, and traits kept flat and free of personal content. iLearnRight, an education customer, identifies students by guid with a role and a time zone, and nothing else.

Identify with an emailidentify("jane@school.org", {name: "Jane D."})

Every event, session, export, report and AI prompt now carries a direct identifier. A screenshot of the People page is a personal-data incident.

Identify with a guididentify("3f2c…9a1", {role: "student", tz: "Europe/London"})

Sessions still join up across devices and visits. The mapping from guid to person stays in your own system, where your access controls already apply.

We should be honest that our own install page and README show an email trait in the identify example, because many teams want it. A privacy-first deployment should not copy that line. If you do need names — the executive usage report can rank users by a user.name trait — that is a deliberate choice, made per project, and worth writing down.

3. At ingest: keys, origins, and what the server adds

Each project has a write key. It is public by design — it sits in your page source — and it can add events but never read them; reading needs a signed-in user or an API key. Two things stop someone else’s page posting into your project with it: the project’s allowed origins list (a browser request from anywhere else gets a 403), and Rotate key, which issues a new one at once. Native apps send no Origin header, so for them the key alone scopes the project.

Project settings
The write key and Settings panels of the Northwind project: write key ciq_demo_northwind with Copy and Rotate key; allowed origins http://localhost:5225 and http://127.0.0.1:5225; session replay 100% of sessions; inputs Masked; mask selector [data-private]; block selector [data-clickiq-block]; retention 395 days
Every privacy default on one card. Inputs masked, the two selectors, the allowed origins and the retention window. The demo records 100% of sessions for show; production projects usually sample.

The server enriches each event with browser, OS and device type parsed from the User-Agent, and with country, region and city from your CDN’s headers, a MaxMind database if you configure one, or the browser’s time zone. The client’s IP address is used for that lookup and never stored. Neither is the raw User-Agent string.

4. In storage: tenancy, retention and erasure

Tenancy

Every product table carries an organization column under Postgres row-level security, and the application connects as an ordinary role, not a superuser, so the policy actually binds. Routes scope every query as well; RLS is the second line, there for the day a route has a bug. When the event table lives in ClickHouse, which has no RLS, the query builder binds the organisation on every statement and the ClickHouse dialect refuses to execute a statement without an organization = … predicate — a test asserts it on every generated query. The console process should use a read-only ClickHouse user.

Retention

Each project has a retention_days, 395 by default, settable from 7 to 3,650. A daily round at 03:00 local time deletes events older than the cutoff (dropping a whole monthly partition instantly when no project still keeps a row in it), then the replay chunks and their objects in the bucket, sessions that ended before the cutoff, people not seen since, cached AI readings older than 30 days, and dismissed signals older than 90. An admin endpoint reports the last run: on the demo, 4 projects checked in 0.16 seconds, nothing yet old enough to delete, and two live partitions holding 42,192 and 228,798 events.

Erasure

A GDPR or CCPA request is one call. DELETE /api/people/{id} (analyst or admin) collects every id the person was seen under — their user id and each anonymous browser id — and deletes their sessions, events, replay chunks and replay objects, and the person rows, returning the counts. DELETE /api/sessions/{id} (editors) does the same for one session.

Two caveats on deletion

If you run the event table on ClickHouse, its retention is the table’s own TTL (CLICKIQ_CLICKHOUSE_TTL_DAYS, 400 by default), separate from the Postgres round, and the erasure call deletes from Postgres only today. Set the TTL to match your policy and handle erasure requests on ClickHouse explicitly until that is closed. Second, deletion is by the ids the person was seen under: if you identify by email, an erasure request has to give you that email.

5. The AI: what the model sees

InsightClickIQ is AI-first, which makes “what does the model see?” a fair question for every feature. The answer is consistent across all of them: the model plans and words; it never writes or runs SQL. It sees the project’s catalog (event names, property names, top values), numbers the engine computed, and for session summaries the event timeline — which, by everything above, contains no field values and only masked private text. It never sees the replay pixels. Every tool call is pinned to the caller’s organisation.

You choose the provider — Bedrock, Azure OpenAI, Vertex, OCI, or any OpenAI-compatible endpoint, which can be one you host — or none at all. With CLICKIQ_AI=false every AI surface still answers by rule, so an air-gapped deployment loses fluency, not features.

6. Token hygiene

CredentialWhat it can doHow to handle it
Write key ciq_…Add events to one projectPublic. Restrict origins; rotate if abused
Console access tokenEverything the signed-in user can doShort-lived bearer; never in config
Personal API keyThe same, for scripts and MCP clientsDoes not expire with the session — store it like a password, revoke when done
Database and ClickHouse passwordsEverythingSecret refs: aws-sm://, azure-kv://, gcp-sm://, oci-vault://
Token in a stream URLLive view, agent runs, PDF printOpen issue — see below

Configuration checks itself. On the demo, GET /api/health/config warns: “INSIGHT_DATABASE_PASSWORD is set — secrets belong in the platform’s store; use a *_REF / *_SECRET ref”. That is the laptop setup; production should not show it.

One finding from our own QA review is only partly closed. Browsers cannot send headers on an EventSource, so the live stream, agent-run stream and server-side PDF route accept the access token as ?token=. The API’s own logs now redact it; until the planned short-lived stream ticket replaces it, strip token= from any proxy log in front of the API as well.

What the product does not do for you

  • It does not scan your custom properties. track() and identify() send what you pass. If you put an email in a property, it is stored. Keep props flat and free of free text.
  • It records URLs, including query strings. If your site puts personal data in a URL (?email=, a reset token), it will be in the url column. Fix it in the site.
  • It does not manage consent banners. It gives you clickiq("consent", false) and DNT/GPC handling; deciding when to call them is your consent tool’s job.
  • Native apps send only what you call. The iOS, Android and React Native SDKs read nothing from the UI automatically — safer by construction, but a careless tap() label can still carry personal text.
  • Residency is a deployment choice. One database and one bucket per region; do not share a Postgres across regions.
0field values stored, by design
395days of retention by default, 7–3,650
1 callto erase a person across every id
RLSon every product table, plus a ClickHouse guard

The safest data is the data the browser never sent. Most of InsightClickIQ’s privacy model is decided before an event leaves the page.

How masked replays become evidence →  ·  Identify by guid on web and mobile →  ·  Tenancy and retention on ClickHouse →

← All posts   Request a demo