Skip to main content

The Flaggr browser analytics script — setup, version pinning, data attributes, collected events, collection control, delivery, privacy and the ingest

Last updated October 25, 2026

Browser Analytics

Flaggr's browser analytics script records page views, Web Vitals and conversions, and, when you turn them on, clicks, errors and scroll depth. Every event carries the flag values the page is using, so the Analytics Dashboard can split the results by variant and rollout guardrails can compare variants.

The script is about 6 KB gzipped. It sets no cookies, keeps no user identifier, and by default sends nothing when the browser has Do Not Track on. Privacy lists what it sends and what the ingest stores.

Quick Start

First create a site for the web property. In the console, open a service and choose Sites → Onboard site, or have your assistant call the MCP create_site tool. Give the site the domain its pages are served from: events sent from other origins are stored but marked untrusted, analytics views leave them out by default, and rollout guardrails ignore them (see Trust marks). Flaggr generates the site's ID, which starts with site_. You can find it again later in the service's Sites tab: each site's ID is under its domain, with a copy button, and the row menu's Copy analytics tag copies the whole script tag.

Then add the script tag with that ID to your HTML:

<script
  src="https://cdn.flaggr.dev/a.js"
  data-site-id="site_7c1e4a9b2f6d8e0a3b5c7d9f"
  defer
></script>

data-site-id takes the generated site ID. Your project ID or the site's slug won't work: the ingest refuses any other ID. Sites created before Flaggr generated IDs have their slug or a UUID as ID, which the ingest refuses too; create the site again to get a site_… ID.

That's it. The script collects:

  • Page views: the initial load and SPA navigations, with render timing
  • Web Vitals: LCP, CLS, INP, FCP and TTFB
  • Conversions: the flaggr:conversion events your page dispatches
  • Sessions: an anonymous ID per browser tab, kept in sessionStorage
  • Flag variants: the flag values your page publishes (see Flag Variant Correlation)

Events are batched and sent to https://ingest.flaggr.dev/e with fetch (keepalive). When the page is hidden or unloaded, what is left goes in a keepalive fetch of text/plain, or with navigator.sendBeacon where fetch has no keepalive (Firefox before 133) or nothing more fits the browser's 64 KB keepalive allowance (see Transport & Delivery).

Check that it works

Open the browser's network panel. About 5 seconds after the page loads you should see POST https://ingest.flaggr.dev/e answered 202 with the counts, for example {"accepted": 3, "rejected": 0} for the page view, TTFB and FCP (the counts are also in the X-Events-Accepted and X-Events-Rejected response headers). A 202 for a request sent while the page is open means its accepted events are stored.

A 403 with {"error":"Invalid site_id"} means data-site-id isn't one of your sites' IDs. The script then stops sending for that page, so fix the ID and reload. With data-debug="true" the browser console logs each step under [flaggr:analytics].

Content Security Policy

If your pages send a Content-Security-Policy, allow the script's origin in script-src and the ingest in connect-src (which covers both fetch and sendBeacon):

Content-Security-Policy: script-src 'self' https://cdn.flaggr.dev; connect-src 'self' https://ingest.flaggr.dev

Add these sources to your existing directives. If you set data-endpoint, allow that origin in connect-src instead. https://cdn.flaggr.dev also serves the @flaggr/sdk browser bundle (/sdk.js).

Pin a version

/a.js is always the latest release, and browsers may keep a copy for up to an hour (max-age=3600, then a day of stale-while-revalidate). To decide when the script changes, load a pinned file instead. Pinned files (/a-<version>.js) never change, so they can carry a Subresource Integrity hash:

<script
  src="https://cdn.flaggr.dev/a-0.1.9.js"
  integrity="sha384-UbokEwOv+WfuKvg33yULNZRqXEMFKfrK39J4QItWznOG1IXviooN+w65ec5D6izH"
  crossorigin="anonymous"
  data-site-id="site_7c1e4a9b2f6d8e0a3b5c7d9f"
  defer
></script>

https://cdn.flaggr.dev/manifest.json lists every file with its version, size and integrity value, and latest names the version /a.js serves:

curl -s https://cdn.flaggr.dev/manifest.json | jq '.latest["/a.js"], .files["/a-0.1.9.js"].integrity'

crossorigin="anonymous" is required with integrity on a script from another origin.

Pin 0.1.9 (what /a.js serves now) or a later version. The manifest marks three files deprecated, with the reason: a-0.1.0.js and a-0.1.1.js retry failed sends every second, forever, and a-0.1.7.js sends the events queued at page hide twice in Chromium when the visitor follows a link to another page of the same site. Older pins still work but differ from 0.1.9:

  • Before 0.1.7, page-hide events go by navigator.sendBeacon, which Firefox and Safari send with Origin: null and no Referer under a strict Referrer-Policy, so the ingest marks those events untrusted. 0.1.6 and earlier also send a CLS of 0 from Firefox and Safari.
  • 0.1.8 and earlier record a page view for every history.pushState or replaceState call, even one that keeps the URL, so Next.js App Router sites get an extra page view per load. They send no CLS or INP for a page opened in a background tab, and measure TTFB from the request rather than from the start of navigation (see Web Vitals).

A pinned file gets no fixes, so move the pin when the manifest's latest names a newer version.

Configuration

All configuration is via data-* attributes on the script tag; there is no JavaScript API. The script reads them from its own tag (document.currentScript), so load it as a classic script, not type="module".

Required attributes

AttributeDescription
data-site-idThe site's ID (site_…), generated when you create the site in Flaggr. Not your project ID or the site's slug. Without it the script logs a warning and does nothing.

Optional attributes

AttributeDefaultDescription
data-endpointhttps://ingest.flaggr.dev/eWhere batches are posted. An endpoint on another origin must answer the CORS preflight that Content-Type: application/json triggers, send Access-Control-Allow-Origin on its responses (when the browser won't let the script read an answer, the script sends the batch again), and list Retry-After in Access-Control-Expose-Headers for the script to read it
data-track-clicksfalseRecord clicks on links, buttons, submit inputs and elements with data-track
data-track-errorsfalseRecord uncaught errors and unhandled promise rejections
data-track-scrollfalseRecord scroll depth milestones
data-respect-dnttrueSend nothing when Do Not Track is on (navigator.doNotTrack is "1"); "false" sends anyway
data-debugfalseLog to the browser console under [flaggr:analytics]

The data-track-* and data-debug attributes are on only when set to "true"; data-respect-dnt is off only when set to "false".

Full example

<script
  src="https://cdn.flaggr.dev/a.js"
  data-site-id="site_7c1e4a9b2f6d8e0a3b5c7d9f"
  data-endpoint="https://ingest.flaggr.dev/e"
  data-track-clicks="true"
  data-track-errors="true"
  data-track-scroll="false"
  data-respect-dnt="true"
  data-debug="false"
  defer
></script>

Event Types

Every event has type, siteId, sessionId, timestamp (epoch milliseconds), url (the page URL), flags (the page's flag values when the event was recorded) and data, whose fields depend on the type.

Page views (always on)

Captured on the initial load and on each history.pushState or history.replaceState call or popstate event that moves to another path or query string. A call that leaves both as they are is not a new page view: for example the Next.js App Router's replaceState with the same URL on load (and when a refresh or a server action updates the page), or a change of the hash alone (in-page anchors; a hash-routed SPA records one page view per load). A replaceState that changes only the query string, such as one that strips UTM parameters, is a new page view. a.js 0.1.8 and earlier counted every call, so Next.js App Router sites recorded an extra page view per load and higher session page counts. Each page view's data has:

  • path: path and query string
  • title: document title
  • referrer: document.referrer
  • viewportWidth, viewportHeight: viewport size
  • connectionType: effective connection type, where the browser reports it
  • flagsetHash, activeFlagCount: a hash of the page's flag values, and how many there are

The initial load also carries render timing from the document's navigation entry. The script records the page view as soon as it starts, so with defer the phases that finish later (DOM ready and load) are usually missing:

  • renderServerMs, renderNetworkMs, renderParseMs, renderHydrationMs, renderPaintMs, renderTotalMs: the load split into server time (a Server-Timing span named app, origin, server, backend or render when the document has one, else request to first byte), network, HTML parse (to DOM interactive), DOM ready (to DOMContentLoaded) and load (to the load event), and their sum
  • renderBottleneck: the largest of those phases
  • renderNavigationPhases: DNS, connect, TLS, request, response, DOM, ready and load, each with its start and duration (phases with no duration are left out)
  • renderServerTimings: the document's Server-Timing spans

An SPA navigation instead carries the latest same-origin /api/* requests (up to 6) made since the previous page view. The script wraps window.fetch to time them:

  • renderBackendRequests: each request's path, method, status, durationMs and requestId (its x-request-id response header), with serverMs, networkMs and serverTimings from its Server-Timing header
  • renderBackendRequestCount, and renderServerMs, renderNetworkMs, renderTotalMs, renderServerTimings and renderBottleneck over those requests

Web Vitals (always on)

Collected with PerformanceObserver, and sent only where the browser can measure the vital: CLS needs the layout-shift entry type, INP event, LCP largest-contentful-paint and FCP paint, each listed in PerformanceObserver.supportedEntryTypes. Firefox and Safari can't measure layout shifts, so they send no CLS (a.js 0.1.6 and earlier sent a CLS of 0, rated good, from them). Each vital's data has metric (lcp, cls, inp, fcp or ttfb), a numeric value (milliseconds; CLS is a score) and a rating:

MetricGoodNeeds ImprovementPoor
LCP≤ 2500ms≤ 4000ms> 4000ms
CLS≤ 0.1≤ 0.25> 0.25
INP≤ 200ms≤ 500ms> 500ms
TTFB≤ 800ms≤ 1800ms> 1800ms
FCP≤ 1800ms≤ 3000ms> 3000ms

Each vital is sent at most once per page load, so SPA navigations add none:

  • FCP and TTFB as soon as the browser has them. TTFB is the time from the start of the navigation to the document's first byte (responseStart, so it includes redirects, DNS and connection setup), as Lighthouse and the Chrome UX Report measure it. For a prerendered page it counts from activation (responseStart - activationStart, at least 0) and is sent once the page is activated. A browser that reports no responseStart (0) sends no TTFB. Versions before 0.1.9 sent responseStart - requestStart, the server wait alone, so TTFB reads higher from 0.1.9.
  • LCP at the first key press, click or tap, or when the page is first hidden.
  • CLS and INP when the page is first hidden: at its first visibilitychange to hidden, or at pagehide while it is still visible, whichever comes first, in the same request as the rest of the queue. CLS is every layout shift without recent input until then, summed, not the largest 5-second session window that Lighthouse and the Chrome UX Report use, so long-lived pages and SPAs read higher. INP is the longest Event Timing entry the browser reported. Browsers report entries of 104 ms or more by default, so a page whose interactions are all faster sends no INP, and a slow hover handler (pointerover, mouseover) can produce an INP without a click or key press.

A page loaded in a background tab sends no FCP or LCP: it is painted only when first shown, so they would count the time it sat hidden (a prerendered page keeps them). It sends CLS and INP when it is hidden after being shown. A background tab closed before it was ever shown sends TTFB and no other vitals. a.js 0.1.8 and earlier sent no CLS or INP for such a page, and FCP and LCP that included the hidden time.

Two vitals carry attribution under data:

  • elementSelector on LCP: the element's tag, #id and first two classes
  • serverTimings on TTFB: up to four of the document's Server-Timing spans, longest first

Click tracking (opt-in)

When data-track-clicks="true", a click on (or inside) a link, button, submit input or element with a data-track attribute records:

  • selector: #id, else the tag and its first two classes
  • tag: HTML tag name
  • text: the element's text (truncated to 100 characters)
  • trackId: the data-track value, if set
  • href: the link URL, if any

Use data-track="signup-cta" on elements to give them stable identifiers for analytics.

Error tracking (opt-in)

When data-track-errors="true", captures uncaught errors (through window.onerror) and unhandled promise rejections:

  • name, message: the error's name and message
  • source, line, column: where it was thrown
  • stack: stack trace (truncated to 1,000 characters)
  • path: the page's path and query string
  • kind: error or unhandledrejection
  • fingerprint: err_…, a hash of the name, the message, the source and the line, which the issues list groups by. The message leaves out what browsers add to it: Chromium's Uncaught in front, Firefox's uncaught exception: in front of a thrown value that isn't an Error, and the colon Firefox leaves after the name of an Error with no message. The source leaves out its query string and fragment (an inline script's source is the page's URL). The column is left out because Safari counts it from the end of the throw. So an error your app throws with its own message, on one line (as in minified code), has one fingerprint in Chromium, Firefox and Safari. Still per browser: built-in errors, whose messages each browser words its own way (Cannot read properties of null in Chromium, null is not an object in Safari), and, in code that isn't minified, an Error made on another line than the end of its throw (in a helper, an Error subclass's constructor, a multi-line throw). In minified code, errors with the same name and message from one script file are one issue; column tells them apart. message, source, line and column keep what the browser reported. The fingerprint changed in 0.1.7 and again in 0.1.8, so an error reported both from pages on an older script and from pages on 0.1.8 or later can appear as two issues until the old pages are gone.

An error loop can't flood your data. A page sends at most 50 error events, and a page load at most 3 of the same error from the same place (same fingerprint and column). An SPA navigation to another path (a new query string doesn't count) starts a new 50 but not a new 3, so a loop that follows the user stays at 3. The ingest also budgets errors per site (see Ingestion Pipeline).

A window.onerror handler your page set before the script loaded still runs for every error. One assigned after it replaces the script's handler, and uncaught errors are no longer recorded (unhandled rejections still are).

Scroll depth (opt-in)

When data-track-scroll="true", captures scroll depth milestones at 25%, 50%, 75%, and 100% (depth, and the deepest so far as maxDepth). Uses requestAnimationFrame throttling for minimal performance impact.

Conversions (always on)

Record a conversion by dispatching a flaggr:conversion event on window. The script sends it as a custom event marked as a conversion, tagged with the page's flag values like every other event:

window.dispatchEvent(new CustomEvent("flaggr:conversion", {
  detail: { name: "checkout_completed", value: 42 },
}));

name (up to 128 characters) defaults to conversion; value is an optional number such as order value. The event's data is { name, conversion: true, value }.

Flag Variant Correlation

The script attaches the page's current flag values to every event. That's what lets the Analytics Dashboard split vitals, sessions and conversions by variant, and what rollout guardrails read when they compare variants. Guardrails, and analytics views by default, count only events sent from the site's own domain or one of its subdomains; guardrails also count only a capped share from any one visitor address (see Trust marks).

Where the values come from

The script reads a map of flag key to value from the page:

  1. window.__FLAGGR_FLAGS__: { "checkout-v2": true, "pricing-layout": "annual-first" }
  2. window.__FLAGGR_BOOTSTRAP__: the same flags as { "checkout-v2": { "value": true } }, as injected during server rendering. Used only when __FLAGGR_FLAGS__ isn't set.
  3. flaggr:flags-changed: a CustomEvent on window whose detail is the full flag map. It replaces the values the script read earlier, so dispatch it whenever the values change.

@flaggr/sdk 0.5.0 and later publish for you. 0.5.0 is the next release: until npm view @flaggr/sdk version prints it, publish the values yourself as shown below. In a browser, every value the SDK resolves is merged into window.__FLAGGR_FLAGS__ and announced with one flaggr:flags-changed event per burst of changes. That covers the script tag's flaggr.flag(), createFlaggr() clients and the React hooks, including stream and batch updates to flags the page uses and new values after setContext. Keys other code put in the map are kept. An object or array flag is published by its variant name when the evaluation has one; other values over 1,024 characters of JSON aren't published (except _flaggr_analytics), because the script sends the whole map with every event. Nothing is published for a fallback (an evaluation error, a flag that doesn't exist, or your default before anything has loaded), or for a value evaluated for someone else (when the client has a targetingKey, an evaluation whose per-call context gives another one). To keep values off the page, add data-expose-flags="false" to any script tag on the page, or set exposeFlags: false on a client; a client's own exposeFlags overrides the attribute.

With @flaggr/sdk 0.4.0 or earlier (the manifest at https://cdn.flaggr.dev/manifest.json shows which version /sdk.js serves), or another OpenFeature provider, publish the values yourself after you evaluate, merging into the map. With the browser SDK script:

<script type="module">
  const flags = { "checkout-v2": await flaggr.flag("checkout-v2", false) };
  window.__FLAGGR_FLAGS__ = { ...window.__FLAGGR_FLAGS__, ...flags };
  window.dispatchEvent(new CustomEvent("flaggr:flags-changed", { detail: window.__FLAGGR_FLAGS__ }));
</script>

The script records the page view and TTFB as soon as it starts, usually before the first evaluation returns, so those events often carry no flags. Values published by then are on them, and values that arrive later are on every event after that. To have flag values on the first page view, render them into the page (window.__FLAGGR_BOOTSTRAP__ or window.__FLAGGR_FLAGS__) before the script runs.

Each event carries a copy of the map as it was when the values were last published, so changing an object afterwards doesn't change what is sent. A string longer than 256 characters, or an object or array whose JSON is, is sent as a short hash (fs_…) of its content, the same whatever its key order or the visitor's locale, so it still tells variants apart; _flaggr_analytics is always sent as it is. A value JSON can't represent (a circular object, for example) is left out. A request carries at most 32 KB, so with hundreds of flags fewer events fit in each request and the script sends more of them. An event that would be over 32 KB with its flags (around a thousand flags) is sent without them; page views keep flagsetHash and activeFlagCount. When the page is hidden, only the first request sent then carries flags. The script keeps its page-hide requests within what is left of the 64 KB browsers allow keepalive requests in flight, counting its own sends that are still in flight: that first request takes at most half of it, and an event whose flags don't fit goes without them. The page's other scripts share the 64 KB too.

Dynamic Collection Control

Collection can be controlled remotely via the _flaggr_analytics feature flag, allowing you to adjust what data is collected without redeploying. The script only sees the flag once the page has published its value (see Where the values come from). With @flaggr/sdk 0.5.0 and later, await flaggr.flag("_flaggr_analytics", "standard") is enough: the SDK publishes this flag's value in full, whatever its size.

String levels

Set the _flaggr_analytics flag to one of these string values:

LevelCollects
"off"Nothing: the sampling rate is 0, so no event is sent
"minimal"Page views, Web Vitals and conversions
"standard"Page views, Web Vitals and conversions
"detailed"Page views, Web Vitals, conversions, clicks and errors
"full"Everything: page views, Web Vitals, conversions, clicks, errors and scroll

Page views, Web Vitals and conversions are always collected, so "minimal" and "standard" add nothing to the default. Any other string, a boolean or a number means the data-* attributes alone.

Object config

For fine-grained control, set _flaggr_analytics to a JSON object:

{
  "pageviews": true,
  "vitals": true,
  "clicks": false,
  "errors": false,
  "scroll": false,
  "sampling": 1.0
}

clicks, errors and scroll turn those types on. pageviews: false and vitals: false don't turn page views or vitals off; use sampling to send less.

Sampling

sampling is the share of page loads that send events:

  • 0: no page load sends anything
  • 1: every page load (default)
  • 0.5: half of page loads

Each page load draws one random number when the script starts and compares it with the current sampling rate, so flag updates never move a page in and out of the sample at random: lowering the rate only removes pages, and raising it only adds them. A new page load draws again.

Elevation rules

The data-* attributes on the script tag define a floor. The _flaggr_analytics flag can turn on more event types, never fewer: if data-track-clicks="true" is set, clicks are collected whatever the flag says. Only its sampling rate (and "off") sends less, by taking whole page loads out of the sample.

Transport & Delivery

Events are queued in memory and sent to the ingest in batches:

  • 5 seconds after the first event is queued
  • At once when 20 events are waiting
  • When the page is hidden or unloaded, with fetch (keepalive: true, Content-Type: text/plain), or with navigator.sendBeacon() where fetch has no keepalive or nothing more fits the 64 KB allowance

Every send is a fetch with keepalive: true, so a send still in flight when the page goes away completes. A request carries at most 100 events and 32 KB, so a regular send and a page-hide send fit together in the 64 KB browsers allow for keepalive requests in flight. Regular sends are application/json. Page-hide sends are the same JSON array as text/plain: a CORS simple request (no preflight) that carries the page's Origin in every browser. A text/plain sendBeacon doesn't: Firefox and Safari send it with Origin: null and no Referer under a strict Referrer-Policy, and the ingest marks such events untrusted.

The script sizes its page-hide sends to what is left of the 64 KB beside its own requests in flight. What doesn't fit at all goes to sendBeacon, which Firefox doesn't count against the 64 KB (Chromium and Safari refuse it at once). A page-hide send the browser refuses anyway, because other scripts' requests took the room, is tried once more without flags, then kept for the next send. Once the page is going away, Chromium drops such a request without telling the page, so in Chromium those events are lost. Chromium also reports the page-hide requests it did deliver as failed when the visitor follows a link to another page of the same site, so the script doesn't send them again after the page starts to go away.

When the 32 KB limit leaves events waiting (pages with hundreds of flags), the next request goes 2 seconds after the last one is answered, or at once when 20 or more are waiting.

What the script does with the answer:

AnswerWhat the script does
2xxDelivered.
403The site ID is wrong (unknown, malformed or blocked). The script stops sending for the rest of the page view. With data-debug="true" the console shows Site id refused: stopped sending for this page.
Any other 4xx except 408 and 429The batch can never succeed, so it is dropped (logged in debug mode). Later batches still go out.
408, 429, 5xx or a network errorThe batch is retried after 1 s, then 2, 4, 8, 16, 32 and 60 s, each wait randomised to 50–100%, or after Retry-After when the response sends a longer one.

While the ingest is failing the script keeps the newest 200 events and drops older ones. Switching tabs doesn't send them at page hide, which would lose them; they wait for the next retry. When the page is closed or navigated away from (pagehide), they go in the page-hide send as the last chance.

Ingestion Pipeline

Events go to a Cloudflare Worker at https://ingest.flaggr.dev/e. The Analytics API Reference has the full request contract and every status; in short:

  1. Rate limits: 2,400 requests a minute per address (IPv4 address, or IPv6 /64); 60 batches a minute per tab (session ID) and site; 1,200 batches a minute per address and site; and, for site IDs the edge hasn't cached, 300 site lookups a minute per address and per IPv6 /48. Over a limit the answer is 429 with Retry-After: 60. The counters are per Cloudflare location and approximate.
  2. Validation: at most 100 events and 100 KB per request, all for one site; known event types; events nested at most 32 levels deep; timestamps no more than 2 hours old or 24 hours ahead (future-dated events are stored at the time they arrived); browser User-Agents only. Headless Chrome, Firefox and WebKit pass; crawlers and HTTP clients have every event counted as rejected and nothing stored. Typed fields that don't fit their type are dropped.
  3. Site check: the ID must be a site_… ID Flaggr issued, for a site that exists. Unknown, malformed and reserved IDs get 403.
  4. Error budget: at most 20 error events per batch are stored, and each site gets 10 batches with errors per 10 seconds per Cloudflare location. Past that, each tab still stores each distinct error once a minute (up to 3 kinds of error per batch), so every erroring session is still counted. Error events past the budget are counted as rejected; the batch's other events are still stored.
  5. Enrichment and privacy: country (from Cloudflare), browser and version, OS and version, and device class (from the User-Agent) are added; URLs are scrubbed (see Privacy); and every event is marked with its trust marks.
  6. Session upsert: each session's page count and duration are updated. Its flags are those of the batch's events merged in order (later values win); a batch without flags keeps the session's stored flags.
  7. Write: events and session updates are written in one transaction. An application/json batch (what the script sends while the page is open) is stored before the 202 is returned; when the database is unavailable the answer is 503 with Retry-After, and the script keeps the batch and retries it. A batch of any other content type, such as the page-hide sends (text/plain), gets 202 at once and is written in the background, with retries.

The answer is 202 Accepted with { "accepted": N, "rejected": M }.

Trust marks

Site IDs are in your page source, so anyone can post events with yours. The ingest doesn't refuse events from other origins; it marks every stored event's data with what it can vouch for:

KeyMeaning
_srcWho wrote the row: worker (this ingest), sdk (SDK telemetry or mobile events sent with the project's SDK key or an API token), mobile (mobile events sent with a site ID only) or beacon (Flaggr's own landing page)
_untrustedtrue when the request's Origin (or its Referer, when it sends no Origin) isn't the site's domain or a subdomain of it, or it sends neither. Absent otherwise.
_cThe first 12 hex characters of a keyed hash of the sender's address (IPv4 address, or IPv6 /64). The key changes at 00:00 UTC every day, and the hash can't be reversed without Flaggr's secret.

A page can't set these keys: the ingest drops them from the event's own data before adding its marks. What they change:

  • Analytics views and their API reads leave untrusted events out: the /api/analytics/* reads, page-level flag analytics (GET /api/projects/{id}/analytics), a flag's audience top pages, the console Overview's browser vitals, an alert incident's browser impact, and the Performance tab's SDK telemetry report (including what a server instance holds in memory before it is stored). They say how many they hid. Under the totals of the analytics pages and a browser drilldown the console shows, for example, "3 events from other origins were hidden — check the Site's domain (shop.example)." The Overview's Browser vitals panel and an alert's impact read the whole project (its own SDK rows and every Site's), so their note says "… — check that a Site in this project has the domain your pages run on", and the Overview shows "Browser vitals hidden" when every vital was hidden. The MCP tools get_project_overview and get_incident (on the hosted MCP endpoint, and in @flaggr/mcp-server 0.3.0 and later) print the hidden counts. The API returns them in excluded (vitalsExcluded on GET /api/projects/{id}/overview, impact.browserExcluded on an alert firing); the events feed, the flight recorder, a flag's audience top pages and the SDK telemetry report give no count. A project member can count untrusted events too with ?includeUntrusted=1 (see the Analytics API Reference); callers who reach a project only through a public project's floor can't.
  • Some rows count whatever their mark. Mobile app events (type mobile_event) are always counted: apps send no Origin, so the mark can't tell an app's real events from forged ones. The overview's sessions, bounce rate and session duration come from session rows, which carry no marks, so they count every session. SDK evaluation summaries (type eval_summary), which servers send with no Origin, never count as visitors or sessions and never show as hidden events, so a project with a server SDK sees no warning; the SDK telemetry report counts them in its evaluation counts.
  • Site health, rollout guardrails, site guardrails and the RUM optimizer always ignore untrusted events, whatever a reader asks for. Alert rules don't read browser analytics, so posted events can't fire an alert. In a guardrail window one address counts for at most 5 sessions per variant (its earliest in a rollout window, its latest in the day-long windows of site health and the RUM optimizer), and one session for at most 10 samples of each of LCP, INP and CLS and 10 rage or dead clicks (its latest). So someone who posts events with your site ID from a single address adds at most 5 sessions, and 50 samples of each vital, to a variant. These limits bound one address, not a sender with many.
  • Site guardrails and the RUM optimizer, which can change or turn off flags on their own, act only on at least 20 trusted sessions from at least 5 addresses, and not when untrusted sessions from 5 or more addresses outnumber the trusted ones. For site guardrails those addresses are the ones that sent errors (2% of the flag's addresses when that is more than 5), and the verdict must still hold without the 5 addresses that sent the most errors.

Give the site the domain your pages are served from. Subdomains such as www.example.com count as the site's own when the site's domain is example.com, but a parent domain doesn't: with www.example.com registered, events from example.com are untrusted. Pages on an unrelated domain need a site of their own. Page-hide sends from a.js 0.1.7 and later carry the page's Origin. Where the script falls back to sendBeacon (Firefox before 133, or a full 64 KB allowance), and with versions before 0.1.7, Firefox and Safari send page-hide events with Origin: null and no Referer when the page's Referrer-Policy is no-referrer or same-origin, so those events are marked untrusted too.

Privacy

What the script sends and what the ingest keeps:

  • No cookies or user IDs: the session ID is a random ID in sessionStorage (flaggr_a_sid), one per tab, gone when the tab closes. The script doesn't read cookies, localStorage or form fields.
  • Do Not Track: when navigator.doNotTrack is on, the script sends nothing, unless the tag has data-respect-dnt="false".
  • No IP addresses: the ingest never stores or logs a visitor's IP address. It keeps the daily hash in _c (see Trust marks) and the country Cloudflare's edge derives from the address; no city, region or coordinates.
  • URL scrubbing: before anything is stored, the ingest scrubs every URL it keeps: the event's URL, the page path, the referrer, a clicked link's href, an error's source and the paths of backend requests, and with them each session's first URL and referrer. It drops the fragment; replaces with redacted the value of any query or path (;name=) parameter whose name looks like a credential (for example access_token, code, state, password, api_key, X-Amz-Signature, sid, PHPSESSID, SAMLResponse or email, in any case or word style) or whose value holds an email address or a JWT; replaces a path segment that is a JWT, and any user:password@; and keeps only the scheme of mailto:, tel:, sms: and callto: links. In page titles, a click's text and an error's message and stack, it scrubs every absolute http(s) URL the same way and replaces email addresses with redacted. Not scrubbed: tokens and email addresses that are a plain path segment of a stored URL (/reset/<token>, /unsubscribe/jane@example.com), a token inside a URL nested in a parameter (?next=%2Finvite%3Ftoken%3D…), a JWT in text outside a URL, URLs inside text that are relative or not http(s) (wss://…), custom event properties and flag values, so keep personal data out of those. The Analytics API Reference has the full rules.
  • Logs: the ingest logs failures with the site ID and counts, never payloads or addresses. Cloudflare's per-request invocation logs, which would record addresses, are off.
  • Where data goes: the browser sends events to ingest.flaggr.dev (a Cloudflare Worker), which writes them to Flaggr's Postgres database (Neon) under your site's ID. They're read through your project's access rules. Data Processing & Privacy lists the services Flaggr uses.

Backend Performance and SLOs

Browser analytics correlates render timing with backend response metadata, but it should not be your SLO source of truth by itself.

  • Use browser analytics to answer user-experience questions such as "was this slow render caused by backend work or client work?"
  • Use Flaggr's server-side Prometheus and OpenTelemetry metrics to define backend latency and availability SLOs
  • Emit meaningful Server-Timing headers from your document and API responses so the browser analytics payload can carry backend spans alongside page load data

From an AI assistant

With Flaggr's MCP server connected, an assistant can set up a Site and read its data. The flaggr-analytics agent skill teaches it the install, the options above and which tool answers which question.

QuestionMCP tools
Create a Site, or find its idcreate_site, list_sites, get_site
Is the script sending?list_recent_browser_events
Traffic: visitors, page views, bounce rateget_browser_analytics_overview, get_site_session_counts
Web Vitals: percentiles, slow pages, browsers and devicesget_web_vitals, drilldown_browser_analytics
Any metric by page, browser, device, flag or variantquery_browser_segments, export_browser_analytics
Did a flag's variant hurt vitals, errors or conversions?get_flag_rum_report, get_flag_rum_impact
Frustration around a flag (rage and dead clicks)get_flag_frustrations
A Site's health and its linked flags' guardrailsget_site_health, link_flag_to_site, check_site_guardrails
Browser errorslist_issues (see Error Tracking)

Try: "Compare LCP and INP between the variants of new-checkout on the shop site over the last 7 days, by browser."

Next Steps