@klyfa/web
v0.6.0
Published
First-party, cookieless-by-default web analytics for the Klyfa platform. Built on @klyfa/core.
Readme
@klyfa/web
First-party, cookieless-by-default web analytics for sites and web apps on
the Klyfa platform. One SDK, one init, one identity. Built on
@klyfa/core.
Install
npm install @klyfa/web@klyfa/core comes along as a dependency. The package ships compiled ESM and
CommonJS with TypeScript declarations, so it works without asking your bundler
to transpile node_modules — Vite, Astro, webpack (even with
exclude: /node_modules/), Next.js without transpilePackages, Jest, and plain
Node all consume it as-is.
Setup
import {Klyfa} from '@klyfa/web';
Klyfa.init({
apiUrl: 'https://api.klyfa.com',
clientId: 'YOUR_WEB_CLIENT_ID', // the project's web SDK client
});Call init() once, as early as possible. By default a screen_view fires on
load and on every client-side navigation (History API + Astro View
Transitions), so single-page and multi-page apps both work with no extra code.
Tracking
Klyfa.track('cta_click', {cta: 'get_started'});
Klyfa.screenView('/pricing'); // manual page view (optional)
Klyfa.setGlobalProperties({plan: 'pro'}); // attached to every later eventReferrer attribution
The referring URL for the page load rides on the session's first event, so organic and social traffic is attributed rather than counted as direct. Same-origin referrers are dropped — in a multi-page site every internal navigation would otherwise look like a self-referral. UTM parameters are read from the landing path and, failing that, the referrer's query string.
Time on screen
The SDK measures how long each page is actually in front of the visitor and
reports it as a screen_time event when they navigate away or hide the tab.
That event carries the departed page's path and its duration in
milliseconds, so the Pages report's Avg duration column reflects the page it
measured. Time while the tab is hidden is not counted, so an hour in a
background tab adds nothing.
Without it, "time spent" can only be inferred from the gap between events —
which reads a three-minute read with no clicks as zero seconds. The cost is one
extra event per page visit. Turn it off with init({trackScreenTime: false}).
A visit interrupted by tab-switching reports one segment per stretch of
attention rather than one row per visit, so total time on a page is the sum
of its screen_time durations. Stretches under a second aren't reported on
their own — they're carried and folded into the next one, so nothing is lost.
screen_time is instrumentation rather than something the visitor did, so the
server excludes it from bounce rate, session event counts, and page-view
counts. It does appear in the raw event stream.
Storage — cookieless by default
init({storage}) decides whether anything is written to the visitor's device:
| Mode | Stores on device | Identity | Consent banner | Funnels & retention |
|------|------------------|----------|----------------|---------------------|
| 'none' (default) | nothing | server-derived (IP + user-agent) | not required | counted, but the identity rotates monthly |
| 'local' | a stable device id + the offline queue in localStorage | the stored device id | required | counted by a stable id |
| 'cookie' | a stable device id in a first-party cookie; the offline queue in localStorage | the cookie's device id | required | counted by a stable id, shared across subdomains |
'none' is the strict, GDPR-clean default: no cookie, no localStorage, so a
consent banner isn't needed. Choose 'local' when you want a stable per-browser
identity (see identity stitching below) — and pair it with a consent flow.
What the mode costs you in the dashboard. Funnels and retention count
anonymous visitors, so they work without identify() in any mode. But under
'none' a visitor's identity is a hash of their IP + user-agent, and the server
rotates the salt monthly: the same person looks like a new visitor after a
rotation or a network change, which flattens multi-week retention and splits
long funnels. 'local' and 'cookie' send a stable install id that doesn't
rotate. Calling identify() is better still — it's the only thing that follows
one person across devices.
When to choose 'cookie'
'local' is origin-scoped, so www.example.com and app.example.com get
different ids, and nothing on your server can read it. Those are the two — and
the only two — reasons to pick 'cookie':
Klyfa.init({
apiUrl, clientId,
storage: {mode: 'cookie', domain: '.example.com'}, // one jar for every subdomain
consent: 'granted',
});| Option | Default | Notes |
|--------|---------|-------|
| domain | none (host-only) | Must be stated to share the id across subdomains. Never inferred from the hostname — stripping labels breaks on foo.co.uk and on preview hosts like *.vercel.app, where a parent-domain cookie either fails silently or leaks identity between unrelated sites. |
| maxAge | 400 days | Seconds. 400 days is the ceiling Chrome clamps cookie lifetimes to. |
| sameSite | 'Lax' | 'None' is opt-in only (and forces Secure) — pass it only if your site runs in a cross-site iframe. |
| secure | true on https | |
Only the device id goes in the cookie. The offline queue stays in
localStorage: it is JSON that can reach 500 events against a ~4KB cookie
budget, and every cookie is sent on every request to the domain, so a queue in
a cookie would truncate and tax the whole site. The bare string 'cookie'
also works and gives a host-only cookie (server-readable, not shared across
subdomains).
The funnel below needs neither mode: it carries identity across the browser→server boundary as an opaque id you mint yourself.
The consent default follows the storage posture. With 'none' there is
nothing stored and nothing to ask, so consent defaults to 'granted'. With
'local' or 'cookie' it defaults to 'pending' — events buffer in memory
and nothing touches the device until you call setConsent('granted'). Storing
an analytics identifier isn't "strictly necessary" under ePrivacy Art 5(3), so
the SDK won't do it before you say the visitor agreed. If you already have a
lawful basis, say so at init:
Klyfa.init({apiUrl, clientId, storage: 'local', consent: 'granted'});Upgrading from 0.1.x? This is the one behavior change that can lose data. If you use
storage: 'local'or'cookie'and never callsetConsent(), events will now buffer instead of send. Passconsentexplicitly. The SDK logs a warning at init when it applies this default.
The SDK is SSR-safe: it touches no browser globals at import, and on the server it degrades to no storage and no DOM listeners.
Global Privacy Control
If the visitor's browser asserts Global Privacy
Control — Brave and DuckDuckGo send it by
default, Firefox in private windows, plus extensions like Privacy Badger — the
SDK downgrades to cookieless, unlinked tracking, whatever storage you
configured:
- nothing is written to the device — no cookie is set — and anything an earlier visit stored (the client device id in either backing store, the offline queue) is wiped
- no client id is sent, so identity is the server-derived one and only
identify()is ignored — the visitor is never linked across visits- page views and events still count, anonymously
Klyfa.getPrivacySignals();
// → {gpc: true, dnt: false, cookielessEnforced: true}This is stricter than the law requires. GPC is a do-not-sell-or-share signal, and CCPA § 7025(c)(1) says honoring it "is not required for a business that does not sell or share personal information" — which first-party, single-tenant analytics does not. Klyfa honors it anyway, because a visitor asking not to be profiled should not have to know how your vendor is contracted.
Set init({respectGpc: false}) only if you have established the signal doesn't
apply to you: it is legally binding in twelve US states, and enforcement is
active (Sephora, Todd Snyder, Healthline, Tractor Supply).
Don't pop up a notice because GPC is set — § 7025(f) forbids responding to the signal with a notification or interstitial.
getPrivacySignals()is for suppressing a banner that has nothing left to ask, or rendering the "opt-out request honored" status § 7025(c)(6) requires you to display.
Do Not Track, GPC's dead predecessor, is detected but not acted on — no
browser or regulator gives it weight, and several turned it on by default. Opt
in with init({respectDnt: true}) if you want the belt-and-braces posture.
The Klyfa server honors GPC independently, by reading the Sec-GPC: 1 header
browsers attach to the ingest request (per-project toggle, default on). The two
are complements, not duplicates: Brave on iOS sets the JS property but sends no
header, so the SDK catches those visitors; an SDK too old to know about GPC
still sends the header, so the server catches those. respectGpc: false only
turns off the client half — the project setting governs the server half.
Identity & consent
Klyfa.identify(profileId); // opaque id recommended — NOT an email
Klyfa.setConsent('granted'); // 'granted' | 'pending' | 'denied'
Klyfa.clear(); // logout / erasure: reset identity, drop buffered eventsidentify(profileId) ties this visitor's anonymous page views to profileId
(server-side anon→identified stitch). Consent gates delivery: 'pending'
buffers events in memory (nothing sent, nothing persisted) until 'granted';
'denied' drops them.
Two properties of that stitch are worth designing around, because both are easy to discover a year later as a data anomaly rather than up front:
- It reaches 30 days back, and no further. A visitor whose first session was more than 30 days before they identified keeps only the last 30 days of their pre-identify journey. If your acquisition funnel spans longer, measure it from the device id rather than from the profile.
- It runs once, at
identify(), and cannot be reconstructed. There is no backfill job. Skip the call — or defer it past the window — and those page views stay anonymous permanently. This is why callingidentify()with no traits at all is still worth doing.
What it matches on depends on the project's identity mode (Project → Privacy →
Identity mode): standard claims the visitor's device id, authenticated
confines the stitch to a single session so a shared IP+UA can never
retroactively reassign someone else's page views.
Only 'granted' writes to the device. That matters for the usual way to boot a
returning visitor — read your own consent cookie and pass what it says:
Klyfa.init({apiUrl, clientId, storage: 'local', consent: readMyConsentCookie()});With 'denied' this stores nothing at all (no device id, no queue), and calling
setConsent('denied') later removes what an earlier 'granted' session wrote.
identify() is also a no-op for a visitor asserting Global Privacy Control
(see above), so a funnel built on it will under-count rather than mis-count —
those visits stay anonymous page views. Check
getPrivacySignals().cookielessEnforced if your funnel needs to know.
Groups — per-tenant reporting
Klyfa.setGroup('vessel:1'); // every later event is tagged with this tenant
Klyfa.upsertGroup({id: 'vessel:1', type: 'vessel', name: 'MV Nordlys'});A group is an account, not a person — your customer's customer. Tagging
events with one is what makes per-tenant reporting work (filter the dashboard
by group, export one tenant's data). Because it identifies an organisation
rather than an individual, group tagging keeps working for a visitor asserting
Global Privacy Control, where identify() does not.
clear() drops the group along with the identity, so on a shared dashboard the
next person to log in never inherits the previous tenant.
Funnel pattern (browser → server-verified conversion)
Connect anonymous browsing to a server-verified conversion without sending an email to Klyfa — mint an opaque id, identify with it in the browser, and hand the same id to your backend so it can emit the trusted conversion:
const pid = crypto.randomUUID();
Klyfa.identify(pid); // stitches this visitor's page views to pid
await fetch('/api/signup', { // your backend maps pid <-> email privately
method: 'POST',
body: JSON.stringify({pid, email}),
});
// your backend then POSTs /track with a server client secret:
// track 'lead_submitted' { profileId: pid } (source='server', spoof-proof)Because Klyfa never sees the email, only the opaque pid, the funnel
(visit → form → verified lead) is one identity with no PII in analytics.
For a B2B dashboard where colleagues share one office IP, set the project's identity mode to
authenticatedand usestorage:'local'so each browser sends a stable id — otherwise IP+user-agent collisions merge colleagues.
API
| Method | Description |
|--------|-------------|
| init({origin}) | Label which surface this build is ('web', 'pro-web', …) — stamped on every event so the dashboard can separate products sharing one project |
| init(config) | Start the SDK. {apiUrl, clientId, clientSecret?, consent?, storage?, trackScreenViews?, trackScreenTime?, respectGpc?, respectDnt?, debug?} |
| track(name, props?) | Record a custom event |
| screenView(path?, props?) | Record a page view (auto-fired on navigation by default) |
| identify(profileId, traits?) | Identify the current visitor |
| setConsent(state) / getConsent() | GDPR consent posture |
| getPrivacySignals() | What the browser asserted (gpc, dnt) and whether the SDK acted on it |
| setGroup(id) / setGroups(ids) | Tag subsequent events with a tenant/account (a clinic, a vessel) |
| upsertGroup({id, type, name, properties?}) | Create or update a group's own attributes |
| revenue(amount, props?) | Record revenue in minor units; pass currency |
| increment(prop, value?) / decrement(...) | Add to a numeric property on the identified visitor's profile |
| setGlobalProperties(props) | Properties attached to every subsequent event |
| getDeviceId() / getSessionId() | Server-assigned ids (empty until the first event) |
| flush() | Force delivery of buffered events now |
| clear() | Reset identity and drop buffered events |
| destroy() | Tear down listeners (for re-init / teardown) |
License
MIT — see LICENSE.
