@adpharm/silo-analytics
v2.2.1
Published
Custom Segment wrapper with automatic Time on Page tracking
Readme
@adpharm/silo-analytics
A drop-in replacement for @segment/analytics-next that adds automatic Time on Page tracking with built-in guardrails for SPAs (React Router) and SSR environments.
📖 Documentation: silo.docs.adpharm.digital
Features
- Zero-Config Tracking: Automatically tracks
Page Time Spentevents. - SPA Ready: Smartly handles internal route changes by listening to Segment
pageevents + browser back/forward viapopstate. - Tab & Visibility Aware: Pauses/resumes tracking when users switch tabs or minimize the browser; honors the GA4 three-condition rule (visible AND focused AND in pageshow→pagehide).
- Bounded-Exponential Heartbeat: Periodic dispatches on a 30→60→120→240→480→600s schedule cap data loss on browser crash / mobile tab-kill to ≤ one schedule-step.
- Scroll Depth: Each beacon carries
scroll_depth_max_px(deterministic peakscrollY) plus, when the page is scrollable,est_scroll_depth_max_pct+est_scroll_depth_pct. Theest_prefix encodes lossiness — see Scroll Depth section. - Consent-Gated: Client-side consent gate — when the visitor denies the analytics category (or sends GPC), silo collects nothing (no events, beacons, or ubid). Per-writeKey opt-in / opt-out regime.
@segment/analytics-consent-toolsstill gates third-party device-mode pixels. - Data Quality Guardrails:
- 5s Threshold: Ignores "bounces" (sessions under 5 seconds).
- 15m Cap: Prevents skewed data from "zombie tabs" by capping events at 900 seconds.
- Idle Detection: Engagement timer pauses after 30s of zero user input (GA4 "AFK reading" weakness).
- Reliable Delivery:
pagehide+visibilitychange+ Safari-onlybeforeunload; failed beacons persist tolocalStorageand replay on next page load (50-entry cap).
Installation
Remove the standard Segment package and replace it with this wrapper:
bun remove @segment/analytics-next
bun add @adpharm/silo-analytics
@segment/analytics-nextis a peer dependency — removing your direct install is fine, the package manager keeps it present transitively. You import everything from@adpharm/silo-analyticsinstead (it re-exports the full analytics-next surface unchanged).
Usage
The minimum setup — one call, plugin auto-registered with sensible defaults:
import { AnalyticsBrowser } from "@adpharm/silo-analytics";
export const analytics = AnalyticsBrowser.load({
writeKey: "your-write-key",
env: window.env.PUBLIC_APP_ENV, // optional, namespaces storage keys per env
});Pass
envif your environments share an apex domain (e.g.app.example.comandstaging.example.com). Without it, all envs read the same cookie key and you'll see anonymousIds bleed across deployments. Single-env or different-apex setups don't need it.
Defaults applied internally:
cdnURL→https://cdn.silo.adpharm.digitaltimeOnPage.apiHost→https://event-gateway.silo.adpharm.digital/v1/ttimeOnPage.writeKey→ inherits from the top-levelwriteKey- Storage keys →
ajs_user_id_${writeKey}_${env}andajs_user_traits_${writeKey}_${env}(no suffix ifenvis omitted)
Overrides
Pass anything you want to customize. timeOnPage is Partial<> — only override the fields you care about.
const analytics = AnalyticsBrowser.load({
writeKey: "your-write-key",
env: "production",
cdnURL: "https://your-cdn.example.com", // override
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
});Scroll Depth
Every beacon (heartbeat, internal_nav, terminal) carries up to three scroll fields:
| Field | Type | Always emitted? |
|---|---|---|
| scroll_depth_max_px | integer | yes (when scrollTracking !== false) |
| est_scroll_depth_max_pct | integer | only when page is real-scrollable |
| est_scroll_depth_pct | integer | only when page is real-scrollable |
Convention: est_ prefix means "denominator can shift." The pct fields are computed as scrollY / (scrollHeight - viewportHeight). Both terms can change mid-session — lazy images, font swap, ad injection, infinite scroll. The est_ prefix encodes that lossiness in the field name so analysts can't accidentally build KPIs on the noisy signal without seeing the warning.
The _px field doesn't carry the prefix because scrollY is a deterministic browser-API integer. Trust it. Use it for absolute thresholds ("user scrolled past 2400px").
Reset boundaries. Scroll state resets when page_view_id rolls — i.e. SPA navigation (analytics.page() or popstate) and bfcache restore. Tab-switch-back does NOT reset (same page-view from the server's perspective).
Reliability across rendering modes (rough estimates, not measured):
- Static / classic MPA:
_px~95% reliable,_pct~90%. - SSR with client-routing (Next App Router, Remix): same as MPA for first page, ~80% on subsequent SPA navs — requires
analytics.page()on route change. - Client-only SPA:
_px~85%,_pct~70% (denominator unstable longer post-load). - Infinite-scroll feeds:
_pxreliable,_pctnear-meaningless — the prefix is doing its job here.
To disable entirely: timeOnPage: { scrollTracking: false }. All three fields are then suppressed on every beacon.
Server-Side Deduplication
Every beacon carries three identifiers so your server can collapse retries that the in-process dedup flag can't catch (e.g. a sendBeacon that the browser silently re-delivers, or a heartbeat racing its successor):
| Field | Location | Meaning |
|---|---|---|
| messageId | top-level | UUID, unique per beacon — the dedup key for "is this the same physical send?" |
| properties.page_view_id | per beacon | UUID, stable for one view of one URL; rolls on SPA nav (analytics.page() / popstate) and bfcache restore, not on tab-switch-back |
| properties.sequence | per beacon | integer counter, increments per beacon within a page_view_id |
Collapse on messageId for exact-retry dedup; group by (page_view_id, sequence) to reconstruct a single page-view's beacon stream in order.
The full beacon shape is exported as the
SiloBeacon/SiloBeaconProperties/SiloBeaconContextTypeScript types — import them into your server-side ingestion code so the wire contract stays in sync with the library.
Consent
Consent is a client-side gate: when the visitor's CMP denies the analytics category (or GPC is on), silo sends nothing — no events, beacons, or ubid. Per-writeKey opt-in / opt-out regime; @segment/analytics-consent-tools still gates third-party device-mode pixels. Wire it once with consent.getCategories (+ onConsentChanged for live updates).
The full guide is the single source of truth — how it works, precedence, opt-in/out, map, offlineFallbackMode, ubid, and CMP recipes (OneTrust / TCF / custom): Consent docs.
Remote Settings
On boot, load() fetches the CDN settings JSON for your writeKey itself, hands it to analytics-next (which then skips its own fetch — request count unchanged), and reads a custom silo block out of it. Features can be toggled and tuned per writeKey from the settings document, with no redeploy of the consuming site:
{
"integrations": { "...": "standard Segment fields" },
"silo": {
"timeOnPage": { "enabled": true, "idleSeconds": 60 },
"consent": { "mode": "opt-out" }
}
}Precedence: plugin defaults < code config < remote settings (remote wins). The block is partial — omitted fields fall through to code config. If the settings fetch fails, 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.
The block's shape is exported as the SiloRemoteSettings / SiloRemoteTimeOnPage / SiloRemoteConsent types. Full option tables: Remote Settings docs.
