This is the full developer documentation for Silo Analytics
# Silo Analytics
> Silo's Segment-compatible analytics SDK — a drop-in @segment/analytics-next wrapper that delivers first-party events to the Silo CDP.
**`@adpharm/silo-analytics`** is Silo’s browser SDK for sending analytics into the **Silo CDP**. At its core it’s a thin wrapper around `@segment/analytics-next`, so it speaks the same Segment-compatible API you already know — `track`, `page`, `identify`, source middleware, plugins. You swap one import; events flow first-party into Silo with sensible defaults.
```ts
import { AnalyticsBrowser } from "@adpharm/silo-analytics";
export const analytics = AnalyticsBrowser.load({
writeKey: "your-write-key",
});
```
## Why it exists
[Section titled “Why it exists”](#why-it-exists)
Silo needs a first-party collection SDK that teams can adopt without relearning their instrumentation. Rather than invent a new API, this library wraps `@segment/analytics-next` and keeps it **Segment-compatible** — existing `analytics.track`/`page`/`identify` calls, middleware, and plugins keep working unchanged. What the wrapper adds is Silo-aware defaults (CDN + event gateway wired automatically) plus a set of built-in tracking plugins that enrich every event, so you get useful data out of the box instead of a blank SDK.
Segment-compatible
A drop-in replacement for `@segment/analytics-next`. Keep your existing Segment instrumentation, middleware, and plugins — just change the import.
First-party delivery
Events are sent first-party to your Silo event gateway; the CDN and API hosts are wired by default from your `writeKey`.
SPA-ready
Internal route changes are detected via Segment `page` events + browser back/forward (`popstate`); per-view tracking resets cleanly.
Batteries included
Built-in tracking plugins (engagement, scroll depth) auto-register with sensible defaults and ship deterministic, dedup-friendly beacons.
## Built-in tracking
[Section titled “Built-in tracking”](#built-in-tracking)
On top of the Segment-compatible base, the SDK auto-registers tracking plugins that enrich what you collect — no wiring required:
* **Time on Page** — an idle-aware page-engagement event on every view, with bounce/zombie-tab guardrails and a crash-resilient heartbeat. See [Time on Page](/time-on-page/).
* **Scroll depth** — each beacon carries deterministic `scroll_depth_max_px` plus estimated percentage fields when the page is scrollable. See [Scroll Depth](/scroll-depth/).
* **Reliable delivery** — `pagehide` + `visibilitychange` + Safari-only `beforeunload`; failed beacons persist to `localStorage` and replay on next load.
* **Server-side dedup** — every beacon carries `messageId`, `page_view_id`, and `sequence` so retries collapse cleanly. See [Server-Side Dedup](/server-side-dedup/).
## Next steps
[Section titled “Next steps”](#next-steps)
[Installation ](/install/)Swap the Segment package for the wrapper.
[Quick Start ](/quick-start/)One call to get tracking live.
[Configuration ](/configuration/)Every override option, generated from the type.
[Remote Settings ](/remote-settings/)Toggle and tune features without a redeploy.
# Use with an AI agent
> A prompt to paste into your coding agent so it can add Silo to your site, and the plain-text docs it reads.
If you’re adding Silo to a site with a coding agent (Claude Code, Cursor, Codex, Copilot, whatever you use), paste this prompt into it. Swap in the write key your Silo contact gave you.
Prompt
```text
Add Silo Analytics (@adpharm/silo-analytics) to this site. My write key is YOUR_WRITE_KEY.
Read the docs first: https://silo.docs.adpharm.digital/llms-full.txt
Silo wraps Segment's analytics-next, so if anything you remember about Segment disagrees with those docs, go with the docs.
1. Install it the way the Installation page says: from npm if this site has a build step, or with the CDN script tag if it doesn't. If the site already uses @segment/analytics-next, replace it and change the imports.
2. Set it up as in Quick Start, including the initial page view. If this is a single-page app, call analytics.page() on every client-side route change.
3. If the site has a cookie banner, connect it the way the Consent page shows. Whether Silo collects before the visitor chooses (opt-in or opt-out) is set for this write key on Silo's side, so you don't need to decide that.
4. To test locally, pass debug: true to load(), check the Network tab as Checking Your Events describes (or tell me what to look for), then take debug: true back out.
5. When you're done, tell me what you changed and what the Network tab showed.
```
The prompt sends the agent to a plain-text copy of these docs. It’s rebuilt from this site on every deploy, so it says the same thing these pages do:
* [llms-full.txt](https://silo.docs.adpharm.digital/llms-full.txt) has every page.
* [llms-small.txt](https://silo.docs.adpharm.digital/llms-small.txt) is a shorter cut, for agents that can’t take the whole thing.
* [llms.txt](https://silo.docs.adpharm.digital/llms.txt) is an index of the two.
# Checking Your Events
> Confirm events are arriving with the console debugger or your browser's dev tools, why the analyst MCPs can be up to 30 minutes behind, and the usual reasons an event never shows up.
The quickest way to check that events are arriving is the source’s **Debugger** tab in the Silo console. Every time it refreshes (every 5 seconds) it first fetches anything still on its way, so a page view should show up within seconds of the page loading.
## Without console access
[Section titled “Without console access”](#without-console-access)
If you can’t get into the console, your browser’s dev tools will tell you most of it. Open the **Network** tab and reload the page.
1. Look for the request to `cdn.silo.adpharm.digital/v1/projects//settings`. It should come back `200`. A `404` means the write key is wrong.
2. Each event is a request to `event-gateway.silo.adpharm.digital`. A `200` reply means Silo took it, unless the reply says `"blocked": "localhost"` (see [below](#not-showing-up-at-all)).
Once you see accepted events, ask your Silo contact to check the debugger for you.
## Not in the analyst MCPs yet
[Section titled “Not in the analyst MCPs yet”](#not-in-the-analyst-mcps-yet)
Silo saves events in batches about every 30 minutes, so tools that read the stored data directly, like the Silo analyst MCPs, can be up to half an hour behind. If an event is in the debugger but not there yet, it isn’t lost. Give it time.
The [Read API](/read-api/) fetches waiting events before it answers, so it’s normally current.
## Not showing up at all
[Section titled “Not showing up at all”](#not-showing-up-at-all)
* **You’re testing from `localhost`.** The gateway drops localhost events unless debug is on. Pass `debug: true` to `load()` while you test, and take it out before you ship. See [`silo.debug`](/remote-settings/#silodebug).
* **The visitor didn’t consent.** Silo sends nothing for a capability the visitor hasn’t allowed. See [Consent](/consent/).
* **It’s the first page view of a fresh visit.** On sites with device-mode destinations, a page view fired before the library finishes loading can be dropped. See [The initial page view](/quick-start/#the-initial-page-view).
## Seeing an event twice
[Section titled “Seeing an event twice”](#seeing-an-event-twice)
A retried send never becomes a second row: Silo stores each `messageId` once. Several Time on Page rows for one page view are separate beacons, not duplicates. See [Server-Side Deduplication](/server-side-dedup/).
# Privacy & Compliance
> How Silo's collection controls map to GDPR/ePrivacy, Québec Law 25, PIPEDA and CCPA/CPRA — and where the line sits between Silo and your CMP.
This page maps Silo’s collection controls to the major privacy regimes so you can configure a property correctly for its audience. It is **not** a treatise on the law and the heavy lifting of consent capture is **not** Silo’s job — see the responsibility split below.
Not legal advice
This is engineering guidance on Silo’s controls, not legal advice. Privacy law and its enforcement evolve; confirm your obligations with counsel and the authoritative regulator sources linked at the bottom of this page before relying on any configuration described here.
## Silo vs. your CMP — who does what
[Section titled “Silo vs. your CMP — who does what”](#silo-vs-your-cmp--who-does-what)
Consent is decided **in the browser** and **your CMP is the single source** (see [Consent](/consent/)). Silo ships **no banner**: it honors whatever your CMP reports and gates its own collection accordingly. So the work splits cleanly, and only the left column is something Silo can enforce:
| Silo enforces (configure it) | Your CMP is responsible for (Silo can’t enforce) |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Gating collection until consent (`consent.mode: opt-in`) | A banner whose **Accept All / Reject All** are equally prominent on the first layer |
| ubid **off by default**, as its own consent category | Capturing a genuine, **freely-given** choice (asymmetric reject = invalid consent) |
| Honoring **GPC** unconditionally | Logging consent (timestamp + version) and a persistent re-entry point to withdraw |
| Failing safe on a CDN outage (`offlineFallbackMode`) | Surfacing the notice that profiling tech exists and how to activate it |
If rejecting is harder than accepting, the consent your CMP collects can be legally invalid — at which point Silo’s gate is faithfully honoring a consent signal that doesn’t hold up. Getting the banner right is a precondition for everything below; Silo can’t substitute for it.
## Regime → Silo control
[Section titled “Regime → Silo control”](#regime--silo-control)
| Regime | Core requirement (plain-language) | How you configure Silo |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **GDPR + ePrivacy** (EU/EEA) | Prior, affirmative consent before non-essential collection or device access. | `consent.mode: opt-in` for the property; set `consent.offlineFallbackMode: "opt-in"` so a CDN outage fails closed. See [opt-in vs opt-out](/consent/#opt-in-vs-opt-out). |
| **Québec Law 25** | Opt-in consent; **profiling/identification functions must be off by default** (§8.1); profiling that analyzes health/behaviour is sensitive PI → specific, informed consent. | Same `opt-in` mode. ubid is already **opt-in by default**, independent of `consent.mode` — see [ubid in sensitive contexts](#ubid-in-sensitive-contexts). |
| **PIPEDA** (rest of Canada) | Meaningful consent; express consent for sensitive data. | `opt-in` or `opt-out` per property via `silo.consent.mode` in [Remote Settings](/remote-settings/); use `opt-in` for sensitive/health audiences. |
| **CCPA / CPRA** (California) | Opt-**out** model; must honor an opt-out preference signal (GPC). | `opt-out` default is acceptable; Silo **honors GPC unconditionally** as that opt-out signal — see [GPC](/consent/#gpc-and-dnt). |
`mode` is per-writeKey and lives in the CDN settings (`silo.consent.mode`), so it’s tunable without a redeploy. Set EU/EEA and Québec properties to `opt-in`.
## ubid in sensitive contexts
[Section titled “ubid in sensitive contexts”](#ubid-in-sensitive-contexts)
ubid (universal browser id) is browser-fingerprinting-class profiling tech, so it carries the strictest requirements — and Silo’s defaults are already built for them:
* **Off by default, always.** ubid defaults to `opt-in` **regardless** of the property’s `consent.mode`. Nothing is computed, persisted, or stamped until the visitor consents — this is what satisfies Law 25 §8.1’s “off by default” rule at the SDK level. See [ubid](/consent/#ubid).
* **Its own consent category — don’t bundle it.** Silo maps the `ubid` capability to a CMP category of the **same name** by default, *separate* from `analytics`. On health/sensitive or patient-facing properties, **keep it that way**: wire a dedicated, plainly-labelled CMP category for ubid so it’s a specific, informed choice rather than swept into a generic “Accept All”. Use [`consent.map`](/consent/#mapping-capabilities-to-your-categories) only to match your CMP’s category name — not to fold ubid into analytics.
Worth a hard look
Because fingerprinting is the most-scrutinised technique under current EU/UK enforcement and opt-in rates for it are low, a consented ubid on a sensitive- audience property will be sparse and self-selected. Where you already gate authenticated users, a consented first-party identifier may deliver comparable analytics value with far less regulatory surface. This is an architecture decision, not a Silo setting — but worth raising before relying on ubid there.
## Authoritative sources
[Section titled “Authoritative sources”](#authoritative-sources)
Silo’s behaviour is documented here; for the law itself and current enforcement, go to the regulators directly rather than relying on this page.
[EDPB (GDPR / ePrivacy) ](https://www.edpb.europa.eu/)Guidelines on Article 5(3) ePrivacy — scope incl. device fingerprinting.
[CAI — Commission d'accès à l'information du Québec (Law 25) ](https://www.cai.gouv.qc.ca/)Law 25, incl. §8.1 default-deactivation of profiling functions.
[OPC — Office of the Privacy Commissioner of Canada (PIPEDA) ](https://www.priv.gc.ca/)Meaningful-consent guidance and sensitive-data expectations.
[CPPA — California Privacy Protection Agency (CCPA/CPRA) ](https://cppa.ca.gov/)Opt-out rights and opt-out preference signals (GPC).
# Configuration
> Every timeOnPage override option, generated from the library type.
`AnalyticsBrowser.load` takes a single config object. The top level accepts the standard `@segment/analytics-next` load settings and options (`writeKey`, `cdnURL`, `integrations`, `user`, …) plus two silo-specific keys: `env` and `timeOnPage`, and an optional `consent` block (see [Consent](/consent/)).
Everything here is **code config** — the middle layer of the precedence chain. A `silo` block in the CDN settings JSON can override these values per writeKey without a redeploy (see [Remote Settings](/remote-settings/)).
## `timeOnPage` options
[Section titled “timeOnPage options”](#timeonpage-options)
`timeOnPage` is `Partial<>` — override only what you need; `apiHost` defaults to the Silo event gateway and `writeKey` inherits from the top level.
Tip
The table below is **generated from the library’s `TimeOnPageConfig` type** at build time. It cannot drift from the shipped API — if a default changes in the source, this page changes with it.
| Option | Type | Default | Description |
| ------------------- | ------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventName` | `string` | `"Page Time Spent"` | Event name sent with the beacon. |
| `minSeconds` | `number` | `5` | Minimum seconds before sending an event. |
| `maxSeconds` | `number` | `900` | Maximum seconds to cap the event at (15 minutes). |
| `idleSeconds` | `number` | `30` | Seconds of zero user input after which the engagement counter pauses (audit §3 — GA4’s documented “AFK reading” weakness). Any of mousemove, mousedown, keydown, scroll, touchstart, click, wheel restarts it. Set to 0 to disable idle gating entirely. |
| `heartbeatSchedule` | `number[] \| false` | `[30, 60, 120, 240, 480, 600]` | Bounded-exponential heartbeat schedule in seconds (audit §6.3). Each fire dispatches a beacon with `exitType: "heartbeat"` carrying the engagement delta since the last beacon, then schedules the next fire at the NEXT step. The last value in the array caps and repeats forever. Schedule is clock-based, not engagement-based: a step advances whether or not the dispatch actually went out (sub-threshold deltas are skipped but the schedule still ticks). Resets to step 0 on navigation boundary and on visibility-return — both signal a fresh engagement window. The default is the audit recommendation. Pass `false` to disable heartbeats entirely. Pass a custom array to override (mostly useful for tests). |
| `scrollTracking` | `boolean` | `true` | Document-relative scroll-depth tracking. When enabled, each beacon carries `scroll_depth_max_px` (deterministic peak scrollY since the page-view started) plus — when the page is actually scrollable — `est_scroll_depth_max_pct` and `est_scroll_depth_pct`. The `est_` prefix on the pct fields signals lossiness: the denominator (`scrollHeight - viewportHeight`) can shift mid-session under lazy image / font swap / ad injection, so the percentage is directional, not authoritative. Set to false to suppress all three fields entirely. |
The values in that table are also exported as a runtime const, so tooling that needs the effective default per field (e.g. a settings editor) reads one source instead of hardcoding them:
```ts
import { TIME_ON_PAGE_DEFAULTS } from "@adpharm/silo-analytics";
```
## Notes on individual options
[Section titled “Notes on individual options”](#notes-on-individual-options)
### `idleSeconds` — the AFK-reading gate
[Section titled “idleSeconds — the AFK-reading gate”](#idleseconds--the-afk-reading-gate)
GA4’s documented weakness is that it keeps counting engagement while a reader is idle. `idleSeconds` pauses the engagement timer after that many seconds of zero user input; any of `mousemove`, `mousedown`, `keydown`, `scroll`, `touchstart`, `click`, or `wheel` restarts it. Set to `0` to disable idle gating entirely. Typical thresholds run 15–60s depending on content type (long-form reading tolerates a higher value). See [Time on Page](/time-on-page/#idle-detection).
### `heartbeatSchedule` — bounded-exponential backoff
[Section titled “heartbeatSchedule — bounded-exponential backoff”](#heartbeatschedule--bounded-exponential-backoff)
The heartbeat caps data loss on a browser crash or mobile tab-kill to ≤ one schedule-step. The schedule is **clock-based, not engagement-based**: a step advances on every fire whether or not the dispatch actually went out (sub-threshold deltas are skipped, but the schedule still ticks). It resets to step 0 on a navigation boundary and on visibility-return — both signal a fresh engagement window. Pass `false` to disable heartbeats entirely. See [Time on Page](/time-on-page/#heartbeat).
### `scrollTracking`
[Section titled “scrollTracking”](#scrolltracking)
Toggles the three scroll-depth fields on every beacon. See [Scroll Depth](/scroll-depth/) for the full field contract and the `est_` convention.
## Storage-key namespacing
[Section titled “Storage-key namespacing”](#storage-key-namespacing)
`env` only namespaces storage keys — it does **not** tag events. Cookies and `localStorage` become `ajs_user_id_${writeKey}_${env}` and `ajs_user_traits_${writeKey}_${env}` so dev/staging/prod don’t share identities across the same eTLD+1.
Shared apex domains MUST pass \`env\`
Without `env`, the key collapses to `ajs_user_id_${writeKey}` and cross-env bleed happens if your environments share an apex domain — `analytics-next` sets cookies apex-scoped via its `tld()` probe. Server-side environment routing is a separate concern.
# Consent
> Wire your CMP once — it gates silo's own collection client-side and drives third-party device-mode pixels.
Consent is decided **in the browser**. Your CMP is the single source: pass `consent.getCategories` (and `consent.onConsentChanged` for live updates) and it drives two things at once:
1. **Silo’s own collection** — a client-side gate over everything silo sends itself: core events to the gateway, time-on-page beacons, and ubid. Denied → silo sends **nothing** for that capability.
2. **Third-party device-mode pixels** — `@segment/analytics-consent-tools` still wraps analytics-next to gate (and stamp) device-mode destination pixels.
No consent stamping on silo’s own events, no backend enforcement — the gate decides client-side and silo simply doesn’t send when denied.
## How it works
[Section titled “How it works”](#how-it-works)
```text
your CMP (getCategories / onConsentChanged) ← single source
│
┌───────┴────────┐
▼ ▼
silo consent gate @segment/analytics-consent-tools
(client-side) (client-side)
├─ core events └─ third-party device-mode pixels
│ → silo gateway
├─ time-on-page beacons
└─ ubid
```
* **The decision happens entirely in the browser.** Denied → silo never sends. There is no consent flag on silo’s own events and **the backend never reads consent** — events are stored as opaque JSON.
* **The `analytics` capability** gates silo’s own collection: a denied event has its `Segment.io` source turned off (the gateway POST is skipped) while device-mode pixels — gated separately by consent-tools — still fire.
* **`mode` is per-writeKey**, published in the CDN settings JSON by the control plane and tunable without a redeploy — see [Remote Settings](/remote-settings/).
## The silo gate
[Section titled “The silo gate”](#the-silo-gate)
The gate resolves each capability independently. Precedence (highest first):
1. **GPC** (`navigator.globalPrivacyControl`) — when on, **denies every silo capability** and **cannot be overridden** by a CMP opt-in.
2. **Live CMP category value** — the current value for the mapped category, once the CMP has set one.
3. **`mode` default** — the per-writeKey do-nothing default when the visitor has expressed no choice (see [opt-in vs opt-out](#opt-in-vs-opt-out)).
A denied capability means silo emits nothing for it: the core source middleware turns off `Segment.io` for the event, the time-on-page plugin holds its beacons, and ubid is neither computed nor stamped.
GPC and DNT
GPC is honored unconditionally and a CMP cannot opt back in over it. DNT (`navigator.doNotTrack`) is **dropped** — it’s deprecated and no longer read.
## opt-in vs opt-out
[Section titled “opt-in vs opt-out”](#opt-in-vs-opt-out)
The per-writeKey `mode` sets the default applied before the visitor has chosen:
* **`opt-out`** (default) — silo **collects by default** (US / no consent regime). The visitor must deny to stop collection.
* **`opt-in`** — silo **collects nothing until consent** (GDPR / ePrivacy / Quebec Law 25).
`mode` lives in the CDN settings (`silo.consent.mode`) so it’s tunable per writeKey without a redeploy — see [Remote Settings](/remote-settings/). `consent.offlineFallbackMode` in code config is the fallback used **whenever the remote `silo.consent.mode` is absent** — the CDN is unreachable, or its settings load but don’t set `mode`; set it to `"opt-in"` on EU/Quebec properties so a CDN outage fails safe. Setting `"opt-out"` is a no-op (it’s already the built-in fallback).
## ubid
[Section titled “ubid”](#ubid)
ubid (universal browser id) is gated by a consent category, resolved through the same precedence above. Denied → no fingerprint is computed, persisted, or stamped.
What sets it apart from `analytics` is its **default regime: ubid is `opt-in`**, independent of the property’s `consent.mode`. A browser fingerprint is profiling tech, so it collects nothing until the visitor consents — even on an `opt-out` property. To collect ubid by default (US / no-consent-regime properties), set `silo.ubid.mode: "opt-out"` for that writeKey in [Remote Settings](/remote-settings/#siloubid) — the only knob that flips it.
## Identity durability on Safari / iOS
[Section titled “Identity durability on Safari / iOS”](#identity-durability-on-safari--ios)
`anonymousId` lives in script-writable storage (localStorage + a client-side cookie), so it falls under WebKit’s **7-day cap**: on Safari — and *every* iOS browser, since they all run on WebKit — a visitor who returns after more than 7 days without interacting with the site has that storage wiped and is assigned a **new `anonymousId`**. The counter resets on each first-party interaction, so active visitors keep their id; the loss only hits genuine >7-day gaps. Chrome and Firefox on desktop (and Android Chrome) don’t apply this blanket first-party cap, so they’re largely unaffected.
Read unique-user metrics by browser
On Safari/iOS this inflates unique-user counts and under-reports returning visitors and long-window attribution. ubid is the durable unit here — it’s recomputed deterministically from the browser rather than read from storage, so it survives the wipe — **but only when the visitor has consented** (ubid is `opt-in`, see above). Without ubid, treat Safari/iOS unique counts as inflated and segment metrics by browser.
## Mapping capabilities to your categories
[Section titled “Mapping capabilities to your categories”](#mapping-capabilities-to-your-categories)
Silo’s capabilities are `analytics` and `ubid`. By default each looks up a CMP category of the **same name**. Use `consent.map` when your CMP uses different names (OneTrust group labels, TCF purposes, etc.):
```ts
map: { analytics: "statistics", ubid: "deviceStorage" }
```
## Runtime changes need `onConsentChanged`
[Section titled “Runtime changes need onConsentChanged”](#runtime-changes-need-onconsentchanged)
`getCategories` seeds the gate once at load. To pick up a mid-session consent change, register `consent.onConsentChanged`: it’s fanned out to **both** the silo gate and consent-tools, so one CMP subscription re-resolves first-party collection and re-stamps device-mode pixels together. Omit it and the gate uses seed-time consent until the next full page load.
## Custom React banner
[Section titled “Custom React banner”](#custom-react-banner)
analytics.ts
```ts
import { AnalyticsBrowser } from "@adpharm/silo-analytics";
let consent: Record | undefined;
let notifyConsentChanged:
| ((categories: Record) => void)
| undefined;
export const analytics = AnalyticsBrowser.load({
writeKey: "your-write-key",
env: "production",
consent: {
// Single consent source for both consent-tools and silo's gate. Returns the
// banner's current per-category choice; pre-choice it returns all-false, so
// an opt-in writeKey collects nothing until the visitor accepts.
getCategories: () => consent ?? { analytics: false, ubid: false },
// The provided notify callback re-resolves silo's gate AND re-stamps
// consent-tools' device-mode pixels. Required for the banner's accept/deny
// to take effect without a reload.
onConsentChanged: (notify) => {
notifyConsentChanged = notify;
},
// Capability names match the banner's category names here, so no `map`
// needed. Add one if they differ, e.g. `{ ubid: "analytics" }`.
// EU/Quebec property → fail CLOSED on a CDN outage: collect nothing if the
// remote `silo.consent.mode` can't be fetched. (Remote value overrides this
// when reachable.)
offlineFallbackMode: "opt-in",
},
});
// On accept, update the categories and notify — fans out to silo + consent-tools.
export function onAccept() {
consent = { analytics: true, ubid: true };
notifyConsentChanged?.(consent);
}
```
## OneTrust
[Section titled “OneTrust”](#onetrust)
analytics.ts
```ts
import { AnalyticsBrowser } from "@adpharm/silo-analytics";
// OneTrust group ids → category names. The same names drive consent-tools'
// device-mode pixel gating AND silo's first-party gate (via `map` below).
function getOneTrustCategories(): Record {
return {
statistics: window.OnetrustActiveGroups?.includes("C0002") ?? false,
advertising: window.OnetrustActiveGroups?.includes("C0004") ?? false,
};
}
export const analytics = AnalyticsBrowser.load({
writeKey: "your-write-key",
env: "production",
consent: {
getCategories: getOneTrustCategories,
// Bridge OneTrust's change event. Fanned out to BOTH consent-tools
// (re-stamps device-mode pixels) and silo's gate (re-resolves first-party
// collection live) — required for mid-page consent changes to take effect.
onConsentChanged: (notify) => {
window.OneTrust?.OnConsentChanged(() => notify(getOneTrustCategories()));
},
// Map each silo capability to this site's CMP category name. Both silo
// capabilities ride the OneTrust "statistics" (C0002) group here.
map: { analytics: "statistics", ubid: "statistics" },
// US property → fail OPEN on a CDN outage: collect by default if the remote
// `silo.consent.mode` can't be fetched. `"opt-out"` is also the built-in
// fallback, so this line documents intent rather than changing behavior —
// a remote `silo.consent.mode` overrides it whenever reachable.
offlineFallbackMode: "opt-out",
},
});
```
## TCF / IAB
[Section titled “TCF / IAB”](#tcf--iab)
analytics.ts
```ts
import { AnalyticsBrowser } from "@adpharm/silo-analytics";
// TCF purposes → category names. Purpose 8 ("Measure content performance") maps
// to analytics; purpose 1 ("Store and/or access information on a device") gates
// the device-stable ubid. The same names drive consent-tools AND silo's gate.
function getTcfCategories(): Record {
if (typeof window.__tcfapi !== "function")
return { measurement: false, deviceStorage: false };
let result: Record = {
measurement: false,
deviceStorage: false,
};
window.__tcfapi("getTCData", 2, (data, success) => {
if (success && data.cmpStatus === "loaded") {
result = {
measurement: data.purpose.consents[8] === true,
deviceStorage: data.purpose.consents[1] === true,
};
}
});
return result;
}
export const analytics = AnalyticsBrowser.load({
writeKey: "your-write-key",
env: "production",
consent: {
getCategories: getTcfCategories,
// Bridge TCF user actions. Fanned out to BOTH consent-tools (device-mode
// re-stamp) and silo's gate (re-resolves first-party collection live) —
// required for runtime consent changes to take effect.
onConsentChanged: (notify) => {
window.__tcfapi?.("addEventListener", 2, (data, success) => {
if (success && data.eventStatus === "useractioncomplete") {
notify(getTcfCategories());
}
});
},
// Map silo capabilities to the TCF-purpose category names above.
map: { analytics: "measurement", ubid: "deviceStorage" },
// GDPR/ePrivacy property → fail CLOSED on a CDN outage: collect nothing if
// the remote `silo.consent.mode` can't be fetched. GPC still denies
// everything regardless. (Remote value overrides this when reachable.)
offlineFallbackMode: "opt-in",
},
});
```
# Silo Internal Traffic — Privacy
> What the Silo Internal Traffic browser extension collects, why, and what it never does.
**Silo Internal Traffic** is a browser extension for teams that use Silo analytics. It marks your own visits to sites running Silo as “internal” so they’re excluded from that site’s analytics. This page is its privacy policy.
*Last updated: 2026-06-26 · Operator: The Adpharm*
## TL;DR
[Section titled “TL;DR”](#tldr)
We don’t track you. The extension only does anything on sites running Silo analytics, where it marks your traffic as internal so your team’s activity isn’t counted. It never touches any other site.
## What we collect
[Section titled “What we collect”](#what-we-collect)
* **Name and email** — only the values you type into the extension’s onboarding form.
* **An “internal” flag** — indicating your traffic should be treated as internal.
We do **not** collect your browsing history, the contents of pages you visit, or anything from sites that don’t run Silo analytics.
## How it’s stored and used
[Section titled “How it’s stored and used”](#how-its-stored-and-used)
* Your name, email, and on/off preference are stored **locally** in your browser.
* On a page running Silo analytics, the extension writes a **first-party cookie** (your name/email + the internal flag) that Silo reads, so your visit is recorded as internal.
* This is used **only** to exclude your visits from analytics and to see which team members are set up. It is **not sold** and **not used** for anything else.
## What the extension accesses
[Section titled “What the extension accesses”](#what-the-extension-accesses)
The permissions, cookies, and version below are generated from the extension’s own source, so this list always matches what actually ships:
| Item | Current value |
| ------------------------------------- | ----------------------------------------- |
| Version | `0.0.2` |
| Install-time permissions | `storage`, `scripting` |
| Optional host access (granted by you) | `*://*/*` |
| Cookies set (on Silo sites only) | `silo_traffic_type`, `silo_internal_user` |
| Detects a Silo page via | `data-silo-analytics` on `` |
The optional “all sites” access is granted by you during onboarding and is used **only** to detect which pages run Silo — the extension takes no action on any page that doesn’t.
## Your choices
[Section titled “Your choices”](#your-choices)
* Toggle the extension off at any time from its popup.
* Edit or remove your name/email from the popup.
* Revoke site access in your browser’s extension settings, or uninstall, at any time.
## Contact
[Section titled “Contact”](#contact)
Questions about this policy:
# Installation
> Install silo-analytics from npm, or load it from a CDN with a plain script tag.
There are two ways to load the library, depending on whether your site has a build step:
* **[From npm](#from-npm-recommended)** — if you have a bundler. You get types, tree-shaking, and version pinning through your lockfile.
* **[From a CDN](#from-a-cdn-no-build-step)** — for sites without a build step (tag managers, server-rendered templates, plain HTML). A plain `
```
### ES module
[Section titled “ES module”](#es-module)
`silo-analytics.esm.js` is a standard module — import the same named exports you would from the npm package:
```html
```
Everything past `load()` is identical to the npm usage — see [Quick Start](/quick-start/) for `env`, the initial page view, and SPA route changes, and [Configuration](/configuration/) for every option.
One bundle serves every writeKey
The bundle is the same for all sources — the `writeKey` is a **runtime argument** to `load()`, not baked into the file. `load()` fetches that writeKey’s settings from `cdn.silo.adpharm.digital/v1/projects//settings` at runtime (see [Remote Settings](/remote-settings/)). There is no per-writeKey bundle to build or host.
### Versioning
[Section titled “Versioning”](#versioning)
Each release publishes to two paths:
* **Pinned (recommended):** `…/lib/v2.2.1/silo-analytics.min.js` — an exact version. The bytes at a pinned URL never change, so it’s safe to cache hard. **Use this in production** so a new release can’t change behavior under you.
* **Major alias:** `…/lib/v2/silo-analytics.min.js` — tracks the latest release within major `v2`. Convenient for “always get the newest patch,” but updates propagate on the CDN’s cache TTL (not instantly to browsers that already cached it), and a future patch can change behavior without a URL change.
Pin a version in production
Pin the full `vX.Y.Z` URL for anything customer-facing. Reserve the `vMAJOR` alias for quick experiments where auto-updating is worth the loss of determinism.
The snippets above pin `v2.2.1` — the current [release](https://www.npmjs.com/package/@adpharm/silo-analytics) (the CDN version matches the npm version). Bump to a newer pinned version as you upgrade.
## Requirements
[Section titled “Requirements”](#requirements)
* A **browser** runtime. The library is browser-only ESM; analytics only runs in the browser.
* A Segment-compatible **tracking endpoint** (defaults to the Silo event gateway — see [Configuration](/configuration/) to override).
## SSR / server rendering
[Section titled “SSR / server rendering”](#ssr--server-rendering)
Safe to import and call `AnalyticsBrowser.load(...)` from a module that your framework also evaluates server-side (Astro, Next, etc.). `load()` detects the absence of `window` and **no-ops on the server**, then boots normally on the browser — so you don’t need to guard the call yourself or split the import. (Underlying `@segment/analytics-next` is browser-only and throws `window is not defined` if loaded server-side; the wrapper absorbs that.)
Next: [Quick Start](/quick-start/).
# Quick Start
> One AnalyticsBrowser.load call plus the first page view to get tracking live.
The minimum setup is one `load()` call plus the first page view. The Time on Page plugin auto-registers with sensible defaults — you don’t wire anything by hand.
analytics.ts
```ts
import { AnalyticsBrowser } from "@adpharm/silo-analytics";
// The minimum setup — one call, the Time on Page plugin auto-registers with
// sensible defaults (Silo CDN, event gateway, writeKey-namespaced storage).
export const analytics = AnalyticsBrowser.load({ writeKey: "your-write-key" });
// The first page view waits for ready() (see "The initial page view" below).
analytics.ready(() => analytics.page());
```
Pass \`env\` on a shared apex domain
If your environments share an apex domain (e.g. `app.example.com` and `staging.example.com`), pass `env`. Without it, every env reads the same cookie key and you’ll see anonymousIds bleed across deployments. Single-env or different-apex setups don’t need it. See [Configuration](/configuration/#storage-key-namespacing).
## Defaults applied internally
[Section titled “Defaults applied internally”](#defaults-applied-internally)
With just `writeKey` (+ optional `env`), the library fills in:
* `cdnURL` → `https://cdn.silo.adpharm.digital`
* `timeOnPage.apiHost` → `https://event-gateway.silo.adpharm.digital/v1/t`
* `timeOnPage.writeKey` → inherits from the top-level `writeKey`
* Storage keys → `ajs_user_id_${writeKey}_${env}` and `ajs_user_traits_${writeKey}_${env}` (no suffix if `env` is omitted)
## Customizing
[Section titled “Customizing”](#customizing)
Pass anything you want to override. `timeOnPage` is `Partial<>` — only the fields you set change; the rest keep their defaults.
analytics.ts
```ts
import { AnalyticsBrowser } from "@adpharm/silo-analytics";
// Override anything you want to customize. `timeOnPage` is `Partial<>` — only
// the fields you set are overridden; apiHost + writeKey still default.
export const analytics = AnalyticsBrowser.load({
writeKey: "your-write-key",
env: "production",
cdnURL: "https://your-cdn.example.com", // override the Silo CDN default
timeOnPage: {
minSeconds: 10, // partial override; apiHost + writeKey still default
maxSeconds: 1800, // cap events at 30m instead of the 900s default
idleSeconds: 60, // pause engagement after 60s of no input (0 disables)
eventName: "Engagement", // beacon event name (default "Page Time Spent")
heartbeatSchedule: [60, 120, 240], // custom backoff (false disables)
scrollTracking: false, // opt out of scroll-depth fields
},
user: { cookie: { key: "custom_cookie_key" } }, // full override
});
```
See [Configuration](/configuration/) for every option, its type, and default.
## SPA route changes
[Section titled “SPA route changes”](#spa-route-changes)
In a single-page app, fire `analytics.page()` on every client-side route change (the standard Segment integration pattern). The plugin listens for those `page` events — plus browser back/forward via `popstate` — to roll the `page_view_id` and reset the engagement window per view. Without it, a whole SPA session looks like one long page-view.
## The initial page view
[Section titled “The initial page view”](#the-initial-page-view)
`AnalyticsBrowser.load()` returns immediately and **buffers** any call you make before it finishes booting. That buffering is normally invisible — but on a **cold load** (first visit, nothing cached) where the writeKey has device-mode destinations configured, AJS can take a second or more to finish wiring its delivery pipeline, and a page view buffered in that window may not be delivered. The symptom is unmistakable: the very first page view of a fresh visit never reaches the gateway, yet a refresh (everything now cached) sends it fine.
Dispatch the **initial** page view once analytics is ready, so it runs against the fully-wired pipeline instead of the pre-init buffer:
```ts
// Initial load: wait for the pipeline before the first page view.
// Subsequent SPA navigations: ready() resolves immediately — no penalty.
analytics.ready(() => analytics.page());
```
Tip
This only matters for the first page view of a session. Once `ready()` has resolved, `analytics.page()` dispatches directly and you can call it normally on every route change (above).
## Checking it works
[Section titled “Checking it works”](#checking-it-works)
Open the source’s debugger in the Silo console, or check your browser’s Network tab if you don’t have console access. See [Checking Your Events](/checking-events/) for what to expect, and why an event might not show up yet.
# Read API
> Pull events or ask SQL questions of your Silo data from another app, with a scoped API key.
Silo exposes its event store to other apps over HTTPS, read-only, at `https://event-store.silo.adpharm.digital`. There are two ways in, and one key works for both:
* **`GET /v1/events`** — raw event rows, keyset-paginated. For syncing events into your own system.
* **`POST /v1/query`** — any SQL `SELECT` against the same data, JSON back. For answering a question (counts by day, top pages, a funnel) without pulling everything first. **`GET /v1/schema`** tells you what to query.
Every key is scoped to specific sources (sites), and that scope is enforced by the database itself: a query that names a source outside the key’s scope gets zero rows, not an error.
## Get a key
[Section titled “Get a key”](#get-a-key)
Keys are minted in the Silo console under **API keys**. A workspace admin picks a name, the sources the key may read, and an optional expiry. The token is shown once — copy it then. Lost it? Revoke and mint a new one; there is no way to see it again.
Send it on every request as a bearer token. Clients that can’t set an `Authorization` header may use `X-Api-Key` instead; both carry the same secret.
```bash
-H "Authorization: Bearer silo_sk_…"
```
## Ask a question — `POST /v1/query`
[Section titled “Ask a question — POST /v1/query”](#ask-a-question--post-v1query)
Send one `SELECT` or `WITH` statement (no semicolons, no multiple statements) and, optionally, a row `limit`.
```bash
curl -s https://event-store.silo.adpharm.digital/v1/query \
-H "Authorization: Bearer silo_sk_…" \
-H "content-type: application/json" \
-d '{
"sql": "select date_trunc('"'"'day'"'"', created_at) as day, count(*) as events from organic_events group by 1 order by 1 desc",
"limit": 90
}'
```
```json
{
"data": [{ "day": "2026-09-09T00:00:00.000Z", "events": "1432" }],
"total_rows": 1,
"returned": 1,
"capped": false,
"limit": 90,
"max_limit": 1000,
"scope": { "source_write_keys": ["my-site"] }
}
```
`limit` is a ceiling, not a page: the query is wrapped in `LIMIT limit + 1`, so `capped: true` means there was more, and `total_rows` is then the true count. Page in SQL (`ORDER BY … OFFSET`, or keyset on `id`) rather than asking for everything. The default limit is 200 and the maximum is 1000.
The query runs read-only, with a statement timeout, as a database role that can see only your key’s sources. Aggregate in SQL — it is faster and you keep the rows you don’t need on the server.
### What you can query — `GET /v1/schema`
[Section titled “What you can query — GET /v1/schema”](#what-you-can-query--get-v1schema)
The schema reference for the query API, as Markdown, behind the same key:
```bash
curl -s https://event-store.silo.adpharm.digital/v1/schema \
-H "Authorization: Bearer silo_sk_…"
```
It lists the two views you can read (`organic_events` for real people, `all_events` for everything including bots and internal traffic), every column, the shape of the `payload` JSON, and the rules that matter — for example that unique users are counted by `anonymous_id`, and that `created_at` is the event time. It is generated from the live database definition, so read it from the endpoint rather than copying it; if you are handing SQL-writing to a model, give it this document first.
## Pull rows — `GET /v1/events`
[Section titled “Pull rows — GET /v1/events”](#pull-rows--get-v1events)
```bash
curl -sG https://event-store.silo.adpharm.digital/v1/events \
-H "Authorization: Bearer silo_sk_…" \
-d source_write_key=my-site \
-d event="Purchase" \
-d from=2026-08-01T00:00:00Z \
-d limit=100
```
Filters, all optional: `source_write_key` (narrows within the key’s scope), `event`, `type`, `from` / `to` (ISO 8601 on `created_at`; `to` is exclusive), `prop.` for a payload property (`prop.checkout.step=3`), `include` (`organic` by default, `all` for bots and internal traffic too), `order` (`desc` by default), and `limit` (1–1000, default 100).
```json
{
"data": [{ "id": 12345, "created_at": "…", "source_write_key": "my-site", "payload": { } }],
"page": { "limit": 100, "order": "desc", "include": "organic", "has_more": true, "next_cursor": "ZGVzYzoxMjM0NQ", "max_limit": 1000 },
"scope": { "source_write_keys": ["my-site"] }
}
```
Follow `next_cursor` until `has_more` is `false`, keeping `order` the same while you page. Consecutive `from` / `to` windows tile without double-counting.
### Keeping a copy in sync
[Section titled “Keeping a copy in sync”](#keeping-a-copy-in-sync)
To pull only what’s new each time, page with `order=asc` and save the last `next_cursor` you get back. Every page that has rows returns one, including the last. Next time, start from it. A page with no rows returns `next_cursor: null`; keep the one you already have.
Resume from a cursor rather than re-reading time windows. Silo stores events in batches, so an event can arrive up to about half an hour after its `created_at`. A late event can land inside a window you’ve already read, but it always comes after your cursor.
## Status codes
[Section titled “Status codes”](#status-codes)
| Code | Meaning |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400` | Bad request. The body carries `code` (`invalid_body` or `query_rejected`) and, for a rejected query, the database’s own message — a misspelt column, a statement timeout, a non-`SELECT`. |
| `401` | Missing, malformed, revoked, or expired key. All four answer identically. |
| `403` | The key is valid but has no sources assigned. |
| `500` | Something on Silo’s side failed. Logged with the key id, never the token. |
| `503` | Either that surface isn’t configured on the deployment (`code` says which part), or Silo is briefly at capacity (`code: "over_capacity"`, with a `Retry-After` header). Back off for the number of seconds it names and retry the same request. |
## Good to know
[Section titled “Good to know”](#good-to-know)
* **Responses include events up to the moment you call.** Silo stores events in batches, so before answering, these endpoints save any events still waiting. That usually adds about a second. If Silo is already saving a batch, the request waits for it, for up to about 15 seconds. Past that it answers without the newest events, and they show up on your next call. A cursor sync (above) picks them up either way.
* Revoking or rescoping a key in the console takes effect on the next request.
* A key never sees raw IP addresses or any table beyond the two views.
* Keep keys server-side. They are read-only, but they are also the only thing standing between the internet and your event data.
# Remote Settings
> Toggle and tune library features per writeKey from the CDN settings JSON — no redeploy.
On boot, `AnalyticsBrowser.load` fetches the CDN settings JSON for your writeKey (`{cdnURL}/v1/projects/{writeKey}/settings`) **itself**, hands it to `analytics-next` (which then skips its own fetch — the request count is unchanged), and reads a custom `silo` block out of it:
```jsonc
{
"integrations": { "...": "standard Segment fields" },
"silo": {
"timeOnPage": {
"enabled": true,
"idleSeconds": 60
},
"consent": {
"mode": "opt-out"
},
"ubid": {
"mode": "opt-in"
},
"debug": {
"enabled": false
}
}
}
```
Because the settings are served per writeKey from the Silo CDN, features can be toggled and tuned **from the web, without a redeploy** of the consuming site.
## Precedence
[Section titled “Precedence”](#precedence)
Plugin defaults **<** code config **<** remote settings. Remote values win — the point is post-deploy control. The block is partial: fields it omits fall through to whatever the site’s code config (or the library default) says.
If the settings fetch fails — network error, non-2xx, malformed body — the library loads with **pure code config** and lets `analytics-next` fetch the settings itself. Remote unreachable behaves exactly like a library without remote settings.
## `silo.timeOnPage`
[Section titled “silo.timeOnPage”](#silotimeonpage)
Tip
Generated from the library’s `SiloRemoteTimeOnPage` type at build time — it cannot drift from the shipped contract.
| Option | Type | Default | Description |
| ------------------- | ------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
| `enabled` | `boolean` | `true` | Kill switch — `false` skips plugin registration entirely (no listeners, no middleware, no beacons). |
| `minSeconds` | `number` | `—` | Minimum seconds before sending an event. |
| `maxSeconds` | `number` | `—` | Maximum seconds to cap the event at. |
| `idleSeconds` | `number` | `—` | Seconds of zero user input after which the engagement counter pauses. `0` disables idle gating entirely. |
| `heartbeatSchedule` | `number[] \| false` | `—` | Bounded-exponential heartbeat schedule in seconds. `false` disables heartbeats entirely. |
| `scrollTracking` | `boolean` | `—` | Document-relative scroll-depth tracking. |
A `—` default means the field **falls through to the site’s code config** when absent — see [Configuration](/configuration/) for the library defaults. Only `enabled` has a remote-level default, because it controls library behavior no code config can express.
`enabled: false` skips plugin registration entirely: no listeners, no middleware, no beacons. `apiHost`, `writeKey`, and `eventName` are **deliberately not** remote-tunable (endpoint redirection attack surface / downstream data-contract changes).
## `silo.consent`
[Section titled “silo.consent”](#siloconsent)
| Option | Type | Default | Description |
| ------ | ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | `ConsentMode` | `"opt-out"` | Per-writeKey consent regime — the do-nothing default applied when the visitor has expressed no choice. `"opt-out"`: silo collects by default (US / no consent regime). `"opt-in"`: silo collects nothing until consent (GDPR/ePrivacy/Law 25). The code-config `offlineFallbackMode` is the fallback when the CDN settings are unreachable. |
`mode` sets the per-writeKey **do-nothing default** for silo’s own collection — flipping a property’s regime from the web is a single edit here, no redeploy. What `opt-in`/`opt-out` mean, how GPC and the visitor’s live CMP choice take precedence, and the `offlineFallbackMode` fallback are all documented in **[Consent](/consent/)** — the single source for consent behavior.
## `silo.ubid`
[Section titled “silo.ubid”](#siloubid)
| Option | Type | Default | Description |
| ------ | ------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | `ConsentMode` | `"opt-in"` | Per-writeKey consent regime for ubid. Default (`"opt-in"`) lives in `SILO_REMOTE_DEFAULTS` (defaults.ts): a browser fingerprint is profiling tech, so it collects nothing until consent. Set `"opt-out"` per writeKey to collect ubid by default (US / no-consent-regime properties). This is the only knob that flips it, and is deliberately remote-only — opting ubid OUT of consent is a deliberate act, never a CDN-unreachable fallback. |
ubid (universal browser id) is its own consent capability with its own regime, overridable per writeKey here. Unlike `consent.mode`, ubid **defaults to `opt-in`**: a browser fingerprint is profiling tech, so it collects nothing until the visitor consents — independent of the property’s `consent.mode`. Set `"opt-out"` for a writeKey to collect ubid by default (US / no-consent-regime properties). This is the only way to flip it, and is **deliberately remote-only** — there is no code-config fallback, so opting ubid out of consent is always a deliberate per-writeKey act, never a CDN-unreachable default. GPC still denies ubid regardless. See **[Consent](/consent/)** for how the gate resolves.
## `silo.debug`
[Section titled “silo.debug”](#silodebug)
| Option | Type | Default | Description |
| --------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `boolean` | `false` | Master debug switch. `true` stamps `context.silo.debug` on every event (so the gateway allows this writeKey’s localhost traffic) and enables verbose client-side logging. |
Debug mode is a per-writeKey developer switch. It resolves like every other setting (remote **>** code config **>** default `false`), but its effect reaches past the browser: when on, the library stamps `context.silo.debug` on every event, and the **event gateway** uses that stamp to decide whether to accept localhost traffic.
By default the gateway **drops events sent from `localhost` / `127.0.0.1`** — local development is test noise, not real analytics data, so it never reaches your event store or the debugger. Turning debug on (here, or with `debug: true` in the site’s `AnalyticsBrowser.load` config) makes the gateway let that writeKey’s localhost events through so you can watch them land while developing. Flip it back off — or just leave it off in production — and localhost stops reaching the pipeline again.
## TypeScript contract
[Section titled “TypeScript contract”](#typescript-contract)
The block’s shape is exported as `SiloRemoteSettings` / `SiloRemoteTimeOnPage` / `SiloRemoteConsent` / `SiloRemoteUbid` / `SiloRemoteDebug` — import them into whatever serves or edits the settings JSON so both ends of the contract compile against one type:
```ts
import type { SiloRemoteSettings } from "@adpharm/silo-analytics";
```
The defaults shown in the tables above (the time-on-page `enabled` kill switch and the per-capability consent `mode`s) are also exported as a runtime const, so an editor can render the effective default without hardcoding it:
```ts
import { SILO_REMOTE_DEFAULTS } from "@adpharm/silo-analytics";
```
The `enabled` kill switch is mirrored by the library’s branch logic; the `consent`/`ubid` `mode`s are read at runtime as the final fallback. The editor and these docs read this const so the default shown is always the one the contract ships.
# Scroll Depth
> The scroll-depth fields on every beacon and the est_ convention.
When `scrollTracking` is enabled (the default), every beacon — heartbeat, internal-nav, and terminal — carries up to three scroll fields. They ride along on the existing engagement beacon; there is no extra dispatch.
| Field | Type | Always emitted? |
| -------------------------- | ------ | ------------------------------------- |
| `scroll_depth_max_px` | number | yes (when `scrollTracking` is on) |
| `est_scroll_depth_max_pct` | number | only when the page is real-scrollable |
| `est_scroll_depth_pct` | number | only when the page is real-scrollable |
## The `est_` convention
[Section titled “The est\_ convention”](#the-est_-convention)
**`est_` means “the denominator can shift.”** The percentage fields are computed as `scrollY / (scrollHeight - viewportHeight)`. Both terms can change mid-session — lazy images, font swap, ad injection, infinite scroll — so the percentage is *directional*, not authoritative. The prefix encodes that lossiness in the field name, so an analyst can’t accidentally build a KPI on the noisy signal without seeing the warning.
`scroll_depth_max_px` carries **no** prefix because `scrollY` is a deterministic browser-API integer. Trust it. Use it for absolute thresholds (“user scrolled past 2400px”).
The convention, project-wide
Any field whose value depends on document-layout stability or third-party content gets the `est_` prefix. The test: *could two identical sessions on the same page produce different values because the page rendered differently?* If yes, `est_`. Deterministic fields (timer deltas, `scrollY`, bounding-box rects) do not carry it.
## Why a percentage can *decrease*
[Section titled “Why a percentage can decrease”](#why-a-percentage-can-decrease)
`est_scroll_depth_max_pct` can go **down** between heartbeats — and that’s intentional. `scroll_depth_max_px` is monotonic (a peak), but the denominator `(scrollHeight - viewportHeight)` grows when lazy content loads below the fold. Same pixels over a bigger document = a lower percentage. The `_px` field stays honest; the `_pct` field reflects the page as it actually rendered.
## Reset boundaries
[Section titled “Reset boundaries”](#reset-boundaries)
Scroll state resets when `page_view_id` rolls — SPA navigation (`analytics.page()` or `popstate`) and bfcache restore. **Tab-switch-back does not reset** — refocusing a tab is the same page-view from the server’s perspective, and resetting would discard the user’s pre-switch scroll peak.
## Reliability across rendering modes
[Section titled “Reliability across rendering modes”](#reliability-across-rendering-modes)
Rough estimates, not measured:
| Mode | `_px` | `_pct` |
| --------------------------------------------- | ----------------------------- | -------------------------------------------- |
| Static / classic MPA | \~95% | \~90% |
| SSR + client routing (Next App Router, Remix) | \~95% first page, \~80% later | requires `analytics.page()` on nav |
| Client-only SPA | \~85% | \~70% (denominator unstable longer) |
| Infinite-scroll feeds | reliable | near-meaningless — the prefix earns its keep |
## Setup
[Section titled “Setup”](#setup)
Scroll tracking needs no extra wiring — it rides the engagement beacon. In an SPA, fire `analytics.page()` on each route change so the scroll peak resets at the right boundary (see [Reset boundaries](#reset-boundaries)).
analytics.ts
```ts
import { AnalyticsBrowser } from "@adpharm/silo-analytics";
// Scroll tracking is on by default; shown explicit here. Set `false` to
// suppress all three scroll fields on every beacon.
export const analytics = AnalyticsBrowser.load({
writeKey: "your-write-key",
timeOnPage: { scrollTracking: true },
});
// In an SPA, fire `page()` on each client route change. This rolls the
// page_view_id and resets the scroll peak — without it, scroll state would
// accumulate across routes as one long page-view.
export function onRouteChange() {
analytics.page();
}
```
## Disabling
[Section titled “Disabling”](#disabling)
Set `timeOnPage: { scrollTracking: false }` to suppress all three fields on every beacon.
# Server-Side Deduplication
> Why Silo never stores the same event twice, and the identifiers that rebuild each page view's beacons in order.
Because the engagement plugin retries delivery aggressively (`sendBeacon` → `fetch` keepalive → a replayed `localStorage` queue), and because the browser itself can silently re-deliver a `sendBeacon`, Silo will sometimes receive the same event twice. Every beacon carries three identifiers: Silo uses one to drop those retries, and you use the other two to rebuild each page view’s beacons in order.
| Field | Location | Meaning |
| -------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| `messageId` | top-level | UUID, unique per beacon — the dedup key for “is this the same physical send?” Collapse exact retries on this. |
| `page_view_id` | `properties` | UUID, stable for one view of one URL; rolls on SPA nav (`analytics.page()` / `popstate`) and bfcache restore, not on tab-switch-back. |
| `sequence` | `properties` | Integer counter, increments per beacon within a `page_view_id`. Order by this to reconstruct a view’s beacon stream. |
## How to use them
[Section titled “How to use them”](#how-to-use-them)
* **Exact retries: Silo handles these.** Two sends with the same `messageId` are the same event sent twice. Silo stores each `messageId` once, so a retry never becomes a duplicate row in your data.
* **Reconstruct a page-view** — group by `page_view_id` and order by `sequence`. That yields the ordered beacon stream for a single view of a single URL: the initial dispatch, each heartbeat, and the terminal beacon.
* **Sum engagement correctly** — within a `page_view_id`, each beacon’s `seconds` is the *delta* since the previous beacon (heartbeats use peek-then-drain), so total engaged time for the view is the sum across the sequence, capped at `maxSeconds`.
## Why client-side dedup isn’t enough
[Section titled “Why client-side dedup isn’t enough”](#why-client-side-dedup-isnt-enough)
The plugin has an in-process dedup flag, but it can’t catch retries that happen *outside* the page’s lifetime — a `sendBeacon` the browser re-delivers after unload, or a queued beacon replayed on the next page load. Those only meet on the server, which is why the identifiers travel on the wire. The server is the authoritative dedup boundary.
## Example wire shape
[Section titled “Example wire shape”](#example-wire-shape)
```json
{
"messageId": "ajs-next-...",
"properties": {
"pathname": "/articles/foo",
"seconds": 30,
"exitType": "heartbeat",
"page_view_id": "pv_...",
"sequence": 2,
"scroll_depth_max_px": 1840
}
}
```
# Time on Page
> How the engagement timer measures, pauses, and dispatches time.
The core of the library is an engagement timer that reproduces GA4’s verified mechanics and then closes its documented gaps. This page explains how it decides when time counts, when it pauses, and when it sends.
## The three-condition rule
[Section titled “The three-condition rule”](#the-three-condition-rule)
Engagement time only advances when **all three** of GA4’s conditions hold:
1. The page is **visible** (`document.visibilityState === "visible"`).
2. The window is **focused** (the tab has focus, not just visible behind another).
3. The page is within a `pageshow` → `pagehide` lifecycle window.
The moment any condition drops — tab switch, window blur, minimize, navigation away — the timer pauses and preserves the accumulated milliseconds. When the condition returns, it resumes from where it left off.
Note
The timer uses `performance.now()`, a monotonic clock, rather than `Date.now()`. A wall-clock can jump backward (NTP correction, manual change); a monotonic clock cannot, so an engagement delta can never go negative.
## Idle detection
[Section titled “Idle detection”](#idle-detection)
Beyond visibility and focus, the timer also pauses after `idleSeconds` of **zero user input** — GA4’s “AFK reading” weakness, where a visible, focused, but untouched tab keeps accruing time. Any of `mousemove`, `mousedown`, `keydown`, `scroll`, `touchstart`, `click`, or `wheel` restarts the counter.
The activity set is deliberately curated for non-redundancy: `pointerdown` is omitted because `mousedown` + `touchstart` already cover it, and `touchmove` because `scroll` covers it on most platforms — so no event is listened on twice. Set `idleSeconds: 0` to disable the gate entirely.
## Data-quality guardrails
[Section titled “Data-quality guardrails”](#data-quality-guardrails)
Each guardrail maps to a config option; see [Configuration](/configuration/) for the current defaults.
| Guardrail | Option | Why |
| ----------------- | ------------- | ---------------------------------------------------------------- |
| Minimum threshold | `minSeconds` | Drops “bounces” — very short sessions are noise. |
| Maximum cap | `maxSeconds` | Caps “zombie tab” sessions so a forgotten tab can’t skew totals. |
| Idle gate | `idleSeconds` | Excludes AFK-reading windows. |
These three guardrails set in context, alongside the `heartbeatSchedule` (values shown are the defaults):
analytics.ts
```ts
import { AnalyticsBrowser } from "@adpharm/silo-analytics";
// The data-quality guardrails from the table above, set in context. Each one
// shapes when engagement time counts or dispatches — the timer itself is
// automatic once `load()` runs.
export const analytics = AnalyticsBrowser.load({
writeKey: "your-write-key",
timeOnPage: {
minSeconds: 5, // drop sub-5s "bounces" as noise (default)
maxSeconds: 900, // cap "zombie tab" sessions at 15m (default)
idleSeconds: 30, // pause after 30s of zero input — AFK-reading gate (0 disables)
heartbeatSchedule: [30, 60, 120, 240, 480, 600], // bounded-exponential; last step repeats (false disables)
},
});
```
## Heartbeat
[Section titled “Heartbeat”](#heartbeat)
GA4 sends engagement once, at the end. That loses everything if the browser crashes or the OS kills a backgrounded mobile tab. Instead, this library fires a **bounded-exponential heartbeat** on the `heartbeatSchedule` (the last step repeats forever), each carrying the engagement delta since the last beacon. Worst-case data loss on an abrupt termination is therefore one schedule-step, not the whole session. See [Configuration](/configuration/) for the default schedule.
Two subtleties worth knowing:
* **Clock-based, not engagement-based.** The schedule step advances on every fire regardless of whether a beacon actually went out. Backoff is about server load, not engagement precision.
* **Peek-then-drain.** A heartbeat that fires with a sub-`minSeconds` delta is *skipped* but does **not** drain the accumulator — otherwise a window shorter than `minSeconds` would silently zero the timer every heartbeat and the user’s time would never land.
The schedule resets to step 0 on a navigation boundary and on visibility-return, both of which start a fresh engagement window. Pass `heartbeatSchedule: false` to disable heartbeats.
## Reliable delivery
[Section titled “Reliable delivery”](#reliable-delivery)
End-of-session beacons are sent on `pagehide` and `visibilitychange`, with a Safari-only `beforeunload` fallback (Safari’s `visibilitychange` is unreliable at teardown). Transport degrades gracefully: `sendBeacon` → `fetch` with `keepalive` → a `localStorage` queue (50-entry cap) that replays on the next page load. The [consent gate](/consent/) (GPC + the visitor’s `analytics` choice) is re-checked at both send time and flush time, so a denied beacon is never sent.
## SPA navigation
[Section titled “SPA navigation”](#spa-navigation)
Internal route changes are detected from Segment `page` events (fire `analytics.page()` on each client route change) and browser back/forward via `popstate`. Each navigation rolls the `page_view_id` and resets the engagement window, so a multi-route SPA session is recorded as distinct page-views rather than one long one. See [Server-Side Dedup](/server-side-dedup/) for how the identifiers reconstruct a session.