Privacy
What the native engine stores about a visitor, what each mode without consent stores and sends, and what an access or erasure request can find.
This page describes the code: which values a visit leaves in your database and in the visitor's browser, where each one comes from, and how long it stays. It makes no legal claim and is not advice; what it leaves to you is listed at the end.
Everything below is about the native engine, which stores hits in your own Payload collections, except Modes without consent, which covers every slot. A slot filled by a provider adapter is otherwise a different story, and it is at the end.
What one stored hit holds
One row in analytics-hits, column by column, and where its value came from:
| Column | From | Value |
|---|---|---|
id | derived | a time-ordered id made from ts; on a sale sent with an order id, o, then w. when a browser sent it or s. when server code recorded it, then an HMAC-SHA256 digest of its scope, a refund flag, its goal, the order id and, on a refund, the refund id, keyed from the Payload secret. That is what stores a retried order once. The order id itself is stored in no column, and without the secret it cannot be recovered from the id, however few order numbers there are to try |
ts | the server | the instant ingest accepted the beacon, less the time the tracker says it waited in the page (ageMs); on an engagement, also less its time since its pageview, so it is filed at its pageview's start. Each is believed up to a day |
lastAt | the server | on an engagement only, the instant it arrived |
type | the page | pageview, event, goal or engagement |
name | the page | the event name or the goal slug, capped at 128 |
path | the page | location.pathname as sent, capped at 512; a sale or refund server code records without a path is filed under /orders, the same for every order |
hostname | the request | the host the request carried, capped at 253 (see where the hostname comes from) |
landing | derived | whether a pageview opened a visit from outside the site: a page load whose referrer is another site, or none |
server | derived | whether server code recorded the hit rather than a browser |
ref | derived | on a pageview that opened a visit, the referring site's bare host, lowercased, www. stripped |
device, browser, os | the request | classes derived from the user-agent header: mobile, chrome, ios and their siblings |
language | the request | the first accept-language tag, lowercased (de-de) |
country, region, city | the request | whatever the geo resolver answered, each capped at 128; an anonymous hit keeps country alone |
source, channel, channelVersion | derived | the visit's named origin and its acquisition channel, classified from the referrer host, the campaign keys and the ad click ids |
clickId | the page's query string | the name of the ad click id the query carried (gclid), never its value |
utmSource, utmMedium, utmCampaign, utmContent, utmTerm | the page's query string | the five utm_* values, each capped at 128 |
v | derived | the visitor id of the day, or empty for an anonymous hit and for a server event with no visitor of its own |
mode | the page | how the hit was measured: f in full, c cookieless, a anonymous; empty on a hit stored before 1.0.0-beta.15, which reads as f. A server event is f with identity: 'request' and a with 'none' |
durationMs, scrollDepth | the page | on an engagement row, the time the page was visible and how much deeper it was scrolled since the previous one; a pageview carries a scrollDepth of 0 when scroll capture is on |
props | the page | flat scalars the site sent: at most 20 entries, keys up to 64, string values up to 256; on a sale, also its lines under items, at most 20, each an id and optionally a name, a quantity and a price. An orderId prop sent beside an order is dropped |
value, currency, originalValue, originalCurrency | the page | revenue, normally on a goal completion, and the amount and currency as sent whenever value holds something else: an amount converted to the reporting currency, or 0 when no conversion answered |
goals, conversions, revenue | derived | the goals this hit completed, matched at ingest, each with what it was worth (and its amount before conversion, when it was converted), how many, and what they were worth together |
item | the page | the content item an event or a goal belongs to, as <collection>:<id>, kept only for a collection the install binds as items and, from a page, only with the signature the server wrote for it (the signature itself is not stored). With native({ items: { signature: 'optional' } }) a page's unsigned refs are kept too |
impressions | the page | on an engagement only, the content items seen since the previous one, as the same refs |
scope | the request | the tenant the install's scope.resolve answered on a scoped install, empty otherwise |
A refund server code records with no visitor of its own is stored with the place, device and origin of the visit its sale counts in, copied from that sale's stored hits when the refund is recorded, so it lands in its sale's rows. Its v stays empty: nothing links it to the visitor's other hits.
An anonymous hit, which a native slot set to native({ withoutConsent: 'anonymous' }) sends before an answer and after a refusal (Consent), stores less than the table says: an empty v, country without region or city, and no durationMs, scrollDepth or impressions, since no engagement row is stored for it. Its campaign keys, ref, device, browser, os, language, props and item are kept as for any hit. An anonymous pageview that opened a visit from no referrer is filed as direct; a later anonymous pageview, and an anonymous event or goal without campaign keys, carries no source.
There is no column for the visitor's IP address, and none for the raw user agent: only the three classes derived from it. There is no column for the raw query string or for the referring URL, and none for a name, an email address or an account id, unless the site itself puts one into a path or a prop. There is no session column either: visits are cut from the hits each time they are counted, and nothing about one is stored.
Engagement rows
Time on a page, scroll depth and the content items seen arrive after the pageview, as rows of type engagement. One holds its time (ts, and lastAt for when it arrived), its page's path and hostname, its v, its scope, and the durationMs, scrollDepth and impressions it adds. It holds nothing else: none of the visitor's classes, place or origin, which are on the visit's other hits, and never a name, props, value, goals or item. Its ts is its pageview's start, which the tracker sends with it and ingest believes when it falls within the day before the engagement arrives, and its visitor id is computed with that day's salt, so an engagement sent after midnight carries the same id as its pageview rather than linking that visit to the next day's ids. The tracker sends one when the tab is hidden, when the page is left, when the next page opens, or early, once 50 content items have been seen since the previous one, and never while the browser is excluded. It is delivered while its slot sends, in full or cookieless: a native slot at off that is still pending keeps it in the page's queue until it is granted, and an anonymous slot sends none.
Content items
An engagement can also carry the content items seen on its page, as impressions, a list of refs (<collection>:<id>). A view is therefore one entry on an engagement row, kept with that row, and a number in the item's daily totals once its day is counted. A click on an item is an ordinary event row whose item column names the item. The item's display name, which a page may put in data-analytics-item-name for the vendor slots, never reaches native at all: the tracker leaves it off the wire.
Because the engagement row carries the day's v, its impressions record which items one anonymous visitor saw on one day, and the rows sharing that v show what else that visitor did that day. That record lasts as long as the raw hits do. No address or user agent is stored with it: v is computed from them under the day's salt. While that salt exists, a candidate address and agent can be tested against v; once it is deleted, none can be matched to it again (the visitor id).
No cookie
Native capture sets no cookie, on the server or in the browser. Nothing in the plugin writes document.cookie or a Set-Cookie header.
The tracker's only browser storage is localStorage, and it holds at most these keys:
| Key | Written when | Holds |
|---|---|---|
analytics:exclude | a visit carries a valid exclusion link, or exclude(true) is called | 1. Putting the browser back removes the key rather than writing one: a link ending =0, which needs no token, or exclude(false) |
plausible_ignore, umami.disabled | the browser is excluded, or the site's consent manager refuses that vendor's slot through the gate when its refused level is off and its script may have run (the slot sent on the page, it does not wait and so runs from boot, or the site loads the vendor scripts itself with snippets={false}), on an install with that vendor's slot. A refusal of a slot that never sent writes nothing, and neither does one whose refused level is cookieless. Removed when the slot runs in full on a browser that is not excluded, or when exclusion is cleared while the slot runs in full; until then the key also silences the vendor's cookieless counting | the vendor's own opt-out value |
PostHog's own opt-in and opt-out key, __ph_opt_in_out_<token> | the same, through posthog.opt_out_capturing(), on an install with a PostHog slot; when a PostHog slot runs in full, through posthog.opt_in_capturing(), which stores the opt-in instead; and when a grant is revoked on a slot that counts without consent, which stores 0 and returns the client to cookieless. A refusal before any grant writes nothing. The plugin's snippet sets opt_out_persistence_by_default, so an opt-out also deletes PostHog's own ph_<token>_posthog storage, distinct id included, and keeps none while it holds; an opt-in lets PostHog write it again | written by PostHog's SDK, not by the tracker |
GA4's switch, window['ga-disable-<measurementId>'], is a property of the page, not stored. Closing a GA4 slot whose tag already ran on the page also sets Google's consent mode to denied for that page, which is not stored either. Consent mode stops the tag reading and writing its _ga and _ga_<measurementId> cookies and deletes nothing, so when a grant given on the page is taken back the GA4 sink expires both itself, at / on the host and on every parent domain. Cookies a grant on an earlier page left stay.
No consent decision is stored: the gate lives in the page's memory, and the site's consent manager states its decision again on every page load. What a refusal can leave behind is a vendor's own opt-out from the table above; what a grant can leave behind is PostHog's own opt-in, and after a revoke PostHog's __ph_opt_in_out_<token> is the only key left. 1.0.0-beta.11 kept a decision under the analytics:consent key; the tracker deletes it once a slot runs in full on a page it counts, and never reads it.
The tracker reads analytics:exclude only once a slot runs in full, at boot for a slot that does not wait, or at once for a =0 link; a staff browser is counted by every slot that counts without consent until then. Before any answer, storage is touched only by the exclusion link's own write (a token, or =0 on a browser that was excluded), by a GA4 snippet the server rendered in full reading analytics:exclude (which is how an excluded browser turns that tag off before it reports anything), by posthog-js reading a grant it kept from an earlier page, and by a strict refusal on a site that loads the vendor scripts itself (storage before an answer). A browser with storage blocked reads as not excluded, and nothing throws.
The beacon is a same-origin POST (cross-origin only from a site listed in capture.allowedOrigins), so the browser attaches the site's own cookies to it the way it attaches them to any same-origin request. Ingest reads no cookie and stores nothing from one. The install's own code that ingest calls receives the request, though: scope.resolve, the hostname, reporting timezone and currency resolvers get it without its cookie and authorization headers and without a signed-in user, so a staff member's session never decides where their visit is filed, and a custom geo resolver gets the request's headers as sent, cookies included, and may read them.
The visitor id
Nothing the browser sends identifies a visitor, and no identifier is stored for longer than a day. Each hit carries v, computed at ingest:
v = HMAC-SHA256(key: the day's salt, message: "<client address>|<user agent>|<hostname>"), base64url, first 16 charactersThe salt is 32 random bytes, one per calendar date in the scope's reporting timezone, held in analytics-state. The same browser on two days therefore gets two different ids, and the stored values alone do not connect them. Only someone holding both days' salts and the visit's exact address, user agent and hostname could. A server event that came with neither an address nor a user agent of its own gets an empty v and belongs to no visitor, and so does one recorded with identity: 'none', whatever request it came with. An anonymous beacon gets an empty v too: ingest computes no id for it and fetches no salt.
Old salts are deleted, so that link cannot be rebuilt later. A day's salt expires once the third UTC day after its own has begun, and is deleted by whichever comes first after that: a process fetching a salt for a later day than any before, which on an install receiving hits every day is that same day, or a maintenance run (the daily salts). Once a day's salt is gone, nobody, the operator included, can recompute that day's ids from an address and an agent, or join them to another day's.
While a day's salt exists, whoever holds it can test candidate address, user agent and hostname triples against that day's ids, and for a known user agent and hostname the IPv4 address space, about 4.3 billion values, is small enough to try in full. That is what makes a same-day distinct count exact, and it lasts until the salt is deleted.
The client address
The address behind a beacon is read from the platform's own header under trustedPlatform (x-real-ip on Vercel, cf-connecting-ip on Cloudflare), and otherwise from x-forwarded-for (or x-real-ip), per trustedProxyHops. With neither option set, the visitor can write the address the plugin reads, and a production boot says so once in the log. The address is used for two things: the visitor id above, and the geo lookup below. It is written to no column, no log line and no cache. An anonymous hit's address is not hashed; a geo resolver that looks the address up, such as MaxMind's, still reads it for the country.
The same address is what a cookieless count tells visitors apart by, natively and at a proxied vendor. A wrong trustedProxyHops reads the proxy's address for every visitor and collapses them into one, so a production boot warns when a held slot can count cookielessly while neither option is set (Trusted proxies).
Geo
Country, region and city are resolved at ingest, and nothing leaves your server to do it. The default reads the headers your platform already set, for the platform trustedPlatform names (x-vercel-ip-country, x-vercel-ip-country-region and x-vercel-ip-city on Vercel; cf-ipcountry, cf-region-code and cf-ipcity on Cloudflare), and none at all without it, since a visitor can send those headers themselves. The optional MaxMind path reads a GeoLite2 file from your own disk. No third-party geo service is contacted on either path. A custom resolver is the install's own code, and whatever it calls is the install's own arrangement.
The query string
The tracker sends a pageview the campaign keys of the page's query string and nothing else: the five utm_* keys, each value capped at 128 characters, and the names of the ad click ids it carries (gclid, gbraid, wbraid, msclkid, ttclid, and fbclid, which only names the platform), with 1 in place of the id itself. The rest of the query string never leaves the page, so a session token or an email address that happens to sit in a URL is not on the wire, and ingest stores the values it reads rather than the string.
Turn even that off when your URLs carry identifiers you would rather not have on the wire at all:
import { analytics } from '@10x-media/analytics'
import { native } from '@10x-media/analytics/adapters/native'
export const plugin = analytics({
adapters: [native()],
capture: { gate: 'all', autoCapture: { query: false } },
})Nothing then carries campaign attribution, which costs the install its utm* breakdowns and leaves the channel classifier with the referrer alone. Auto-capture has the full trade.
The referring URL never leaves the page. A same-origin navigation puts the previous page's whole URL into document.referrer, query string included, so the tracker sends only its origin: scheme, host and port. Ingest reads whether it came from another site, and keeps only that site's bare host, on the pageview that opened the visit.
An outbound link or a file download is sent as where it leads, its origin (scheme, host and any port) and path: its query string, fragment and credentials never leave the page, so a signed download link or a reset token in one is not on the wire either.
What your own site puts in a row
path, an event's name and its props are whatever the site sends. A page at /invoices/anna@example.com stores that path, and track('signup', { email }) stores that address. Nothing in the plugin can tell such a value from any other string.
That is the install's data, under the install's control: it is in the site's own URLs and its own track calls, and it is queryable and deletable in analytics-hits through the Local API like any other Payload collection, whose access functions keep REST and GraphQL out. A path and an event name are also values of the daily totals in analytics-aggregates: rows whose dim is page or event hold them in value, and a content item's per-page rows (dim item, dim2 page) hold the path in value2. Removing one there means deleting those rows as well.
Modes without consent
A slot that capture.gate holds captures before an answer and after a refusal at the level capture.withoutConsent gives it, cookieless unless the install says 'off'; once granted it runs in full. What each mode stores and sends, per slot:
| Slot | cookieless | anonymous | full |
|---|---|---|---|
| native | a hit as in the table above, mode c; nothing in the browser | a hit with no v, country only, no engagement row, mode a; nothing in the browser | a hit, mode f; the tracker reads analytics:exclude |
| Plausible, Umami | the vendor's own count; nothing in the browser | not offered | the same count; the vendor's opt-out key cleared if set |
| PostHog | $pageview and events with $cookieless_mode: true and distinct_id: "$posthog_cookieless"; nothing in the browser | not offered | PostHog's persistent id, ph_<token>_posthog cookie and storage, ph_<token>_window_id, __ph_opt_in_out_<token> |
| GA4, opted in | pings with every consent signal denied; nothing in the browser | not offered | analytics_storage granted, _ga and _ga_<id> cookies |
off sends and stores nothing. GA4 is off without consent unless opted in. What each vendor sends and stores has the phases and the revoke.
Storing nothing on the device is not sending nothing. A cookieless request still carries the visitor's IP address and user agent: to native ingest, which computes the visitor id from them under the day's salt and stores neither, to Plausible, Umami and PostHog through the first-party proxy, which forwards User-Agent and the resolved client address in X-Forwarded-For, and to Google directly for GA4. What the vendor computes and keeps from them is under its own terms. An anonymous native hit is the one mode whose stored row has no value derived from the address but its country.
The mode a native beacon names is the page's statement, not proof of consent: ingest stores no more than the mode allows, and a beacon that names none is stored as full (Consent).
Consent
The plugin stores no consent state and makes no legal decision. It records no decision on the server (no column, no log, no event; a hit's mode says how the page measured it, not what the visitor answered), and keeps none of its own in the browser: what a refusal can leave there is a vendor's own opt-out, and what a grant can leave is PostHog's own opt-in (No cookie lists them).
In the browser, capture.gate, capture.withoutConsent and setGate are the whole mechanism. capture.gate says which capture slots wait, and the boot fails without it whenever a slot can fill. capture.withoutConsent says what a slot that waits does before an answer and after a refusal. The site's consent manager calls setGate in the browser to grant or refuse. A slot at off keeps its snippet out of the server-rendered HTML, and its events wait in a queue of at most 100, in the page's memory, until it is granted; a refusal drops them. Consent has the details.
The gate does not reach server code. trackServerEvent, the server purchase and refund, and the form-builder goal action record through native directly. With identity: 'request' they store the v, location, device and language of the visitor behind the request, as a beacon would, with mode f. With identity: 'none' they read none of these from the request, not even a country: the hit belongs to no visitor (a refund still takes its sale's stored attribution, as above), has mode a, and only its scope, timezone and goals come from the request. A call that names neither gets 'none' while capture.gate holds a slot the native beacon could fill, and 'request' otherwise; which identity a server event gets is the site's decision (what the gate does not cover).
Bots
Crawlers, link-preview fetchers, monitors and headless clients are dropped at ingest, decided from the user-agent header before the body is read. A dropped beacon is answered exactly like a kept one and nothing about it is stored. See Bot filtering. By default (capture.skip) the tracker sends nothing from a browser that reports navigator.webdriver, from a draft preview or from Payload's live preview. A vendor snippet the server rendered for a slot that does not wait has already run in the first and the last of those, and counts by that vendor's own rules.
Taking a browser out of capture
A site's own staff keep themselves out of their own numbers with a flag in their browser, analytics:exclude in localStorage. The flag is local to that browser: no server state and no cookie. Three things set it:
- The exclusion link, which is off by default. With
capture.exclusionon, a page opened with?analytics_exclude=<token>(the parameter can be renamed) excludes the browser, and?analytics_exclude=0, which needs no token, puts it back by removing the flag. The token is signed with the Payload secret for the site's scope, so a link anyone could type does nothing. The link is for staff only: the tracker removes it from the address the moment the page opens, and browsers send only the site's origin to other sites by default, never the full address, so the token does not travel on. - The analytics view. Staff with access to the view exclude their browser from its "Your own visits" panel, which opens the site's link in a new tab where the site's own tracker stores the flag, or copy the link for another browser.
- The site's own code, through the tracker's
exclude()orsetExcluded()fromuseAnalytics(), whether or not the link is on.
To exclude a browser, open the analytics view and choose Stop counting this browser beside the site; for another browser or profile, choose Copy link for another browser and open the link there once. Excluding your own visits has the details, including the vendor switches the flag also sets.
Aggregates
What the hits add up to lives in analytics-aggregates: per scope, local day, hostname and dimension value, the counts every metric is computed from (pageviews, events, visitors, visits, bounces, conversions, revenue, time and scroll, and the views of and clicks on content items). There is no visitor id in a row of daily totals: visitors are a count. analytics-state holds the daily salts, a marker per counted day (with a failures count of the times counting it started without finishing), the leases that coordinate counting, and the cursors maintenance resumes from (cursor:maintain:<scope> for a scope's last full pass, cursor:maintain-rotation for whose turn is next); the salts are the only value there that touches a visitor.
An access or erasure request
The plugin writes no identifier of its own. The one visitor-shaped value is a day's id whose key is deleted within days. While that key exists, whoever holds it can test a candidate address, user agent and hostname against the day's ids, and enumerate the IPv4 address space for a known agent and hostname (the visitor id); the hits themselves hold no address, name or account, so they offer no handle to look a person up by without one, and no way to answer "show me this visitor's history" across days.
An anonymous hit has an empty v, so a request naming a visitor cannot find it by any id: it is in the numbers, and nothing on it leads back to a person unless the site put an identifier in its path or props.
What remains is what the site itself sent. If your pages carry an identifier in a path, or your track calls put one in props, those hits are findable by exactly that value, and payload.delete on analytics-hits removes them; a path or an event name is also a value of the daily totals, in value or, on an item's per-page rows, in value2, as above. If they do not, there is nothing to find.
A sale sent with an order id is the one row the plugin itself makes findable: its id is built from a digest of its scope, a refund flag, its goal and the order id (and on a refund, the refund id), keyed from the Payload secret, as above, so whoever holds the order id and the secret, the operator, can compute the digest and find the row. The plugin puts the order id itself in no column, path, prop or daily total. A sale recorded from the browser also carries that day's v, which leads from it to the visitor's other hits of the day, for as long as the raw hits are kept. Deleting the sale's row and the rows that share its v removes that trail. Past the raw retention a sale's row stays as a tombstone for as long as its day's totals (see retention): findable by the order id and the secret still, but with v emptied and nothing about the visitor or the visit left on it.
Retention
| Store | Kept by default | Option |
|---|---|---|
analytics-hits | 90 days, today counted; maintenance deletes older hits once their day is counted for good. A sale's or refund's row is kept past that as a tombstone (its order key, scope, time, page, goal, item and amounts; no v, place, device, campaign or props) for as long as its day's totals | native({ retention: { rawDays } }), null keeps every hit; the tombstones go with aggregateDays |
analytics-aggregates | every day's totals, for as long as the install runs | native({ retention: { aggregateDays } }), at least rawDays |
the daily salts, in analytics-state | until the third UTC day after their own has begun, then deleted as above | none |
the browser's analytics:exclude | until the visitor clears the site's data, or the flag is cleared | none |
Deleting hits and totals takes maintenance: the analytics-maintain task on a jobs runner, or, while no runner keeps it on time, the short runs ingest starts on a site that receives hits. An install with neither deletes nothing until one of them runs.
Retention has the option and the details.
When a vendor captures instead
A slot filled by GA4, Plausible, Umami or PostHog is that vendor collecting the data, under that vendor's own terms. Its script runs in the visitor's browser and sets whatever cookies and storage it sets; this page says nothing about what it keeps or for how long, and the vendor's own policy is the one that applies.
What the plugin does change there:
- Its SDK is served through your origin by the first-party proxy, so the requests are first-party rather than third-party. The proxy relays the visitor's address to the vendor in
x-forwarded-for, which is what keeps the vendor's geo correct; it is the address the install's own trust settings resolved, and none of the platform headers about the visitor's address or place that the proxy lists is relayed besides. It never forwards the request'scookieorauthorizationheader upstream, and never passes aset-cookieback down; the vendor's script sets its own storage directly, in the browser. - The gate and the exclusion flag cover the vendor slot too, as far as they reach. A slot at
offgets no vendor script while its gate is pending or closed, and no slot gets one while the browser is excluded; a slot counting without consent loads its vendor's script in the cookieless variant described above, and the tracker reads the exclusion flag only once some slot runs in full. Excluding the browser sets the vendor's own opt-out switch, and so does refusing a slot atoffwhose script may have run; a GA4 tag that already ran is also told through consent mode that every storage type is denied, since its switch counts only before the page's firstgtag()call. A GA4 snippet the server rendered reads the exclusion flag itself, ahead of that call; it reads no refusal, so a GA4 slotcapture.gatedoes not hold measures each page load until the consent manager closes it again (revoking). After a refusal of a slot atoff, two vendors can still send one last thing of their own: GA4 holds its events for about 5 seconds before sending them, so an event recorded while consented can be sent within about 5 seconds after the refusal (gtag sends nothing more after that, and its_gacookies stay deleted), and Plausible's script sends oneengagementevent for the page viewed while consented, with the time up to the refusal, before it checksplausible_ignore. Umami sends nothing more. Closing a slot deletes nothing else the vendor stored before, apart from GA4's two cookies when the same page had granted them and PostHog's ownph_<token>_posthogstorage. Excluding your own visits states exactly what a slot whose snippet the server rendered can still record.
Not covered
These are the operator's decisions, and nothing in the plugin makes them:
- whether an install needs consent, for which slot and under which category, which is what
capture.gateand your consent manager express; - whether a slot may count before an answer or after a refusal, and in which mode, which is what
capture.withoutConsentexpresses; - whether it needs a notice, a record of processing, a data processing agreement or a legal basis, and which one;
- what the site's own paths, event names and props put into a row;
- what a vendor filling a slot collects, keeps and shares.