@pimasawa/web
v0.2.0
Published
Privacy-first analytics for the web. Zero dependencies, under 8 KB gzipped.
Readme
@pimasawa/web
Privacy-first analytics SDK for the web. TypeScript, zero dependencies, 7.57 KB gzipped.
import analytics from '@pimasawa/web';
analytics.init({ apiKey: 'pk_live_…' });
analytics.track('button_clicked', { button: 'start_free' });That is the whole integration. Page views, including SPA route changes, are already being collected; sessions are already being tracked; nothing else needs wiring.
Contents
- What it does · What it does not do
- Installing · Trying an unreleased build
- Framework guides — Next.js, React, Astro, Vue, plain HTML
- API reference
- Identity and sessions
- Consent and privacy modes
- Autocapture · Delivery
- Feature flags — a separate import,
@pimasawa/web/flags, with experiments in it - Coming from GA4 or Amplitude
What it does
- Event tracking with typed properties
- Automatic page views, including SPA route changes
- Anonymous, resettable identity — no fingerprinting, no cookies
- Session tracking (30 min inactivity, 24 h maximum)
- Batching, retry with jitter, offline queue
- Reliable delivery at page unload via
sendBeacon - Consent API for
anonymous,consent_requiredanddisabledprivacy modes - Honours Do Not Track and Global Privacy Control by default
- Feature flags, as a separate import that costs the core bundle nothing
What it does not do
No fingerprinting. No cookies. No cross-site tracking. No advertising identifiers. No automatic capture of DOM text, form values or input contents — click autocapture is off by default, and even when enabled it never reads text or values.
Design constraints
Never break the host page. No public method throws, under any input. The failure mode is always "no data", never "broken site".
7.57 KB gzipped, enforced in CI against that exact number rather than against a round ceiling.
An analytics SDK that noticeably slows a page is one that gets removed — which would defeat the
reason for choosing a lightweight alternative in the first place. @pimasawa/web/flags is a
second entry point with its own 5.24 KB budget, and importing it adds nothing to the number
above: a page that does not use flags does not download them.
Zero runtime dependencies. This ships into other people's pages; every transitive dependency is their supply-chain risk too.
Installing
pnpm add @pimasawa/webNo registry configuration and no token. The package is on the public npm registry, and the
published tarball is dist/ alone — the compiled bundle, which is what a browser downloads from
your site anyway. The source repository is private and stays that way; the LICENSE inside the
tarball is all-rights-reserved, so what you may do with it is a licence question rather than an
access one.
There is no <script> tag build and no CDN URL. Astro, Next.js, Vite and webpack all consume the
package directly — see the framework guides.
Trying an unreleased build
@pimasawa/web is on npm, so this is no longer how you install it — it is how you run a build that
has not been released yet, which is worth knowing every time you change the SDK and want to see the
change in a real site before it is a version number.
A link, if you want edits to show up as you make them. From a checkout of this repository:
pnpm install && pnpm build # dist/ is what a consumer resolvesThen, in your own site:
pnpm add file:/path/to/analytics-sdk-webpnpm build --watch in the SDK and your site's dev server picks up each rebuild. This is exactly
what examples/nextjs and examples/astro do.
A tarball, if you want to test what a publish would actually install. This is the closer
rehearsal — it goes through files, exports and the packing rules, so a packaging bug shows up
here rather than in the pnpm add of a version that cannot be reused:
pnpm build && pnpm pack # → pimasawa-web-<version>.tgzpnpm add file:/path/to/analytics-sdk-web/pimasawa-web-<version>.tgzPoint it at a local backend while you are trying it:
analytics.init({
apiKey: 'pk_test_…',
host: 'http://127.0.0.1:8080',
debug: true,
});debug: true prints every event as it is queued and every batch as it leaves, which turns "is it
working" into something you can read rather than infer. Turn it off before you deploy.
One thing to know before you conclude it is broken: a newly created ingest key is a 401 for up to
thirty seconds. The ingest replicas refresh their key cache on that interval and no request may
read the database, so revocation is immediate and creation is eventual. Wait, then retry.
Framework guides
Working versions of the first and third live in examples/ and are built by CI, so
they cannot drift from what the SDK actually does.
Next.js (App Router)
init goes in a client component mounted in the root layout, inside useEffect.
// app/analytics.tsx
'use client';
import { useEffect } from 'react';
import analytics from '@pimasawa/web';
export function Analytics() {
useEffect(() => {
analytics.init({ apiKey: process.env.NEXT_PUBLIC_PIMASAWA_KEY! });
}, []);
return null;
}// app/layout.tsx
import { Analytics } from './analytics';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Analytics />
{children}
</body>
</html>
);
}Three things about that, each of which is a bug if you get it wrong.
'use client' is necessary and not sufficient. A client component is still imported on the
server to render the initial HTML, so init() at a module's top level runs in Node — where there
is no document and no page to report a view of. useEffect is the framework telling you the
document exists.
The root layout, not a page. Mounted in a page, the SDK is torn down and rebuilt on every route change, which is exactly what the history patch exists to make unnecessary.
Nothing else is needed for navigation. <Link> calls history.pushState, which the SDK
listens for. Next.js patches that function itself, so what runs is our wrapper and theirs on one
function — that interaction is asserted against a real Next.js build in this repository's browser
suite rather than assumed.
Use next/script for none of this. The SDK is an npm package and goes through Turbopack like any
other import; a <Script> tag would ship a second copy of it.
React
Any React application without a framework — Vite, Create React App, a router of your choosing — initialises once, as high as possible:
// main.tsx
import analytics from '@pimasawa/web';
analytics.init({ apiKey: import.meta.env.VITE_PIMASAWA_KEY });
createRoot(document.getElementById('root')!).render(<App />);At a module's top level is correct here, because a client-only application has a document by the time this module is evaluated. React Router and Vue Router navigations are picked up automatically — both are in the browser test suite.
For a component that tracks, import just the function:
import { track } from '@pimasawa/web';
<button onClick={() => track('start_free_clicked', { plan: 'pro' })}>Start free</button>;That is ergonomics, not size. Importing track alone rather than the whole instance saves 6
bytes gzipped, measured — init pulls in the transport, the identity, the consent gate and
autocapture, which is nearly the whole bundle, so there is nothing left for a bundler to drop. Use
whichever import reads better at the call site.
If your application renders on a server, guard it the way the Next.js example does — or simply
call init() from an effect. Every method is safe to call before init, so the ordering cannot
cost you events.
Astro
Astro is the one target that looks like it needs a <script src="…"> and does not. A
<script> in an .astro component is bundled by Vite, so the import works there unchanged:
---
// src/layouts/Layout.astro
---
<script>
import analytics from '@pimasawa/web';
analytics.init({ apiKey: import.meta.env.PUBLIC_PIMASAWA_KEY });
</script>Put it in the layout rather than in each page: Astro bundles it once and every page loads the same file.
With <ClientRouter /> the navigations become pushState calls and page views come from the
history patch, as they do in a React application. Without it, each link is a full document load and
each load reports its own page view — both work, and neither needs a call in a page.
Vue
// main.ts
import analytics from '@pimasawa/web';
analytics.init({ apiKey: import.meta.env.VITE_PIMASAWA_KEY });
createApp(App).use(router).mount('#app');Vue Router is in the browser test suite for a specific reason: it replaces the current history entry on mount and again after some navigations, so a naive page-view implementation reports two views for one navigation. This one does not.
Nuxt renders on a server, so put the init call in a client plugin
(plugins/analytics.client.ts) rather than at a module's top level.
A site with no build step
There is nothing to install. A page that cannot import integrates against the HTTP ingest
API directly: POST /v1/events with the key in ?k=, Content-Type: text/plain, and a JSON
body of { sdk, sent_at, events }. Its OpenAPI document is openapi/ingest.v1.yaml in the
analytics-api repository, and your dashboard's integration screen links the rendered version.
This is a deliberate absence rather than a gap: shipping an IIFE build and a CDN URL would mean a second artefact to version, a second thing to keep in step with the contract, and a script tag whose contents nobody can pin.
API reference
Every method is safe to call before init — a framework that calls track() from a module's
top level is the normal shape of the code this ships into, not a mistake to design around. Calls
made before init are captured with their own clock reading and their own event_id and replayed
in order once the SDK is ready.
Every method is safe to call at all: none of them throws, under any input.
init(options)
Starts collecting. Calling it twice is ignored, with a line in the console when debug is on.
| Option | Type | Default | What it does |
| ------------------- | ------------------------------- | ------------------------- | ---------------------------------------------------------------------- |
| apiKey | string | — | The project's public ingest key, pk_test_… or pk_live_…. Required. |
| host | string | https://in.pimasawa.com | The ingest host. Override for a local backend. |
| autocapture | boolean \| AutocaptureOptions | true | See Autocapture. |
| debug | boolean | false | Log every event and batch to the console. |
| batchSize | number | 20 | Flush when the queue reaches this. Clamped to 1–250. |
| flushInterval | number | 5000 | Milliseconds before an idle queue is flushed. Clamped to 0–300 000. |
| respectDoNotTrack | boolean | true | Honour Do Not Track and Global Privacy Control. |
| beforeSend | (event) => event \| null | — | Inspect, modify or drop each event. Returning null drops it. |
Anything out of range is clamped rather than refused. batchSize: 1000 gets you 250; refusing the
whole init over it would trade a suboptimal batch size for no analytics at all.
track(name, properties?)
analytics.track('checkout_completed', { plan: 'pro', seats: 4, trial: false });name is lowercase snake_case, at most 100 characters. The $ prefix is a closed set the
platform owns and an SDK may not add to it.
properties is a flat map of strings, numbers and booleans. A nested object or an array is
dropped — that one key, not the event — because the server refuses the whole event over one, and a
key lost here costs one property where the alternative costs everything else on it. null drops
the key rather than storing an absence.
Where the SDK and the server could disagree about a rule, the SDK is the lenient one: an invalid event name is warned about and sent anyway. A client-side check stricter than the server's drops data that would have been accepted, in a visitor's browser, where nothing counts it.
page(path?, properties?)
analytics.page(); // read the URL now
analytics.page('/products/:id'); // a route the SDK cannot name for itselfSends a $page_view. Only needed for a route the SDK cannot derive — a parameterised path you want
grouped, most often. With autocapture on, calling this at startup as well sends two.
reset()
Ends the session and mints a new anonymous id. This is a real reset rather than a gesture: the id is random and never derived from anything about the device, so nothing links the new visitor to the old one. The new id reaches your other tabs immediately.
Call it when somebody logs out.
setConsent(granted)
analytics.setConsent(true); // start collecting, and flush what was held
analytics.setConsent(false); // stop, and eraseSee Consent and privacy modes. Safe before init, which is what a
CMP that resolves early needs.
flush()
Returns a promise that resolves once delivery has actually finished — including a delivery that was
already in flight when you called it, which is the common case, since the queue reaching
batchSize starts one on its own.
await analytics.flush();You rarely need it. Page hide and unload already flush.
createAnalytics()
A fresh, uninitialised instance. For a test that needs a clean one, and for the rare application sending to two projects.
Named imports
import { init, track, page, reset, setConsent, flush } from '@pimasawa/web';The same singleton as the default export, as individual functions. Which one you use is a matter of taste rather than of bytes — see the React guide for the measurement.
Identity and sessions
The visitor is a random UUIDv7 in localStorage — never derived from anything about the device,
so reset() genuinely ends it — shared across every tab of your origin. A reset in one tab
reaches the others.
The session lives in sessionStorage, which makes it per tab: two tabs open at once are two
sessions of one visitor. It ends after 30 minutes with nothing tracked or after 24 hours,
whichever comes first, and a $session_start event is sent in front of the event that begins
one. There is no $session_end — no browser event reliably marks one, so the end is derived
server-side from the last event of the session.
The 30 minutes is a default, not a constant: it is a per-project setting between 1 and 1 440
minutes, served to the SDK by GET /v1/config, so web and native agree for a given project rather
than every project using 30.
If storage is unavailable — Safari's private mode, an embedded context that blocks it, a full
quota — the SDK falls through to sessionStorage and then to memory. It keeps collecting either
way; what is lost is that the identity no longer survives a reload.
No cookies, in any of it.
Consent and privacy modes
The mode is a project setting, not an SDK option. The SDK asks GET /v1/config before it
collects anything and behaves accordingly, so changing a project's mode in the dashboard takes
effect in minutes rather than when every site that installed the package upgrades it.
| Mode | Behaviour |
| ------------------ | ------------------------------------------------------------------- |
| anonymous | Collects by default. An undecided visitor is treated as consenting. |
| consent_required | Collects nothing until setConsent(true). |
| disabled | Collects nothing, ever, and erases anything left from before. |
In consent_required with an undecided visitor, events are held in memory only: no identifier
is generated, nothing is written to localStorage, and no request is sent. Granting flushes what
was held, with the ids and timestamps it was collected with; refusing discards it.
That distinction is the whole point. Storage is the tracking — an identifier persisted before consent means the tracking already happened and the consent was retroactive, and no amount of pausing requests afterwards undoes it.
setConsent(false) erases rather than stops: the anonymous id, the session, the persisted
queue and everything else the SDK put on the device. The one thing that survives is the decision
itself — without it the visitor would be asked again on every page load, which is a banner that
never goes away rather than an answer that was honoured.
Do Not Track and Global Privacy Control are honoured by default and decide the undecided case, so a
visitor who has set either is not collected from — and no request is made to the ingest host on
their behalf, not even the configuration one. An explicit setConsent(true) still wins, because a
browser-wide default is a general answer and a click in your banner is a specific one. Pass
respectDoNotTrack: false to opt out deliberately.
A decision made in one tab reaches the others through the storage event.
Wiring an external consent manager
The SDK accepts a decision; it does not own the UI. Whichever CMP you use, the integration is one call from its callback.
// Usercentrics
window.addEventListener('ucEvent', (event) => {
analytics.setConsent(event.detail?.['Pimasawa'] === true);
});
// Didomi
window.didomiOnReady = window.didomiOnReady || [];
window.didomiOnReady.push((didomi) => {
const apply = () =>
analytics.setConsent(
didomi.isConsentRequired() === false ||
didomi.getUserConsentStatusForVendor('pimasawa') === true,
);
apply();
didomi.on('consent.changed', apply);
});
// OneTrust — group C0002 is "performance/analytics" in the default taxonomy
window.OptanonWrapper = () => {
analytics.setConsent(window.OnetrustActiveGroups?.includes('C0002') === true);
};Call setConsent before init if that is when your CMP resolves — every method is safe to call
first, and calls made before init are replayed in the order they were made.
examples/astro has a working banner, including the part people get wrong: what to do on the
second page load, once the visitor has already answered.
Autocapture
Page views are on by default, including SPA route changes; clicks are off.
analytics.init({
apiKey: 'pk_live_…',
autocapture: {
pageViews: true, // default
clicks: false, // default
hashRouting: true, // default — a `#/`-shaped fragment counts as a route
},
});autocapture: false turns both off. autocapture: true is shorthand for the defaults, and
deliberately does not turn clicks on: opting into the half that reads the DOM has to be the
specific act rather than the general one.
Page views
A $page_view is sent when the SDK initialises and on every route change after it —
history.pushState, history.replaceState, the back button, and #/-shaped hash routes. Route
changes are debounced by 300 ms and a change that lands on the URL already reported is not a
navigation, which between them is what makes one navigation one page view in routers that fire
several history updates for it. Tested against React Router, Vue Router, Next.js and Astro, in
Chromium, Firefox and WebKit.
The patch on history is minimal and reversible. The original runs first, with the caller's this
and arguments, and its return value is handed back untouched; when the SDK stops for good — a
401, a 403, or a project in disabled mode — it is put back, unless something else patched
over it in the meantime.
Each $page_view carries the path, the document title, the referring host (never the URL, and
never your own host — an internal navigation is not a traffic source), and the five utm_* tags
from the address bar.
The query string is stripped from the path by default, because it routinely carries search
terms, tokens and personal data. strip_query_params is a project setting; the SDK reads it from
GET /v1/config and the server applies it either way. A fragment is only ever included when it is
#/-shaped: #section-2 is an anchor and #access_token=… is what an OAuth implicit flow leaves
behind.
analytics.page('/products/:id') is the same call for a route the SDK cannot name for itself.
Clicks
Off by default. When enabled, an autocapture_click records four things about the nearest <a>,
<button>, [role=button] or <summary>:
| Property | From |
| ------------------ | ------------------------------------------- |
| tag | the element's tag name |
| id | its id, when it has one |
| href_host | the host of its href, never the path |
| data-analytics-* | data-analytics-plan="pro" → plan: 'pro' |
Never the text, never a value, never a class. Nothing inside an <input>, <textarea>,
<select> or contenteditable is captured at all, and there is no option to change that. Put
data-analytics-ignore on an element and nothing inside it is ever captured:
<section data-analytics-ignore>
<button id="reveal-account-number">Show</button>
</section>The event is named autocapture_click rather than $click because the $ prefix is a closed set
the platform owns and an SDK may not add to it.
Delivery
Events are queued and sent in batches, never one request per track(). A flush happens when the
queue reaches batchSize (20 by default), when flushInterval elapses (5 seconds), when the page
is hidden or unloaded, and whenever you call flush() — which resolves once delivery has actually
finished, including a delivery that was already in flight when you called it.
At unload the batch goes by navigator.sendBeacon, which is the only transport a browser is
obliged to finish after the document is gone. If the browser refuses it — there is a 64 KiB
ceiling, shared across everything in flight on the origin — the batch is halved and offered again,
down to a single event, and only then does it fall to fetch.
Anything not delivered is retried with exponential backoff and full jitter, and kept in
localStorage so it survives the page: up to 100 events or 100 KB, oldest dropped first, restored
and sent on your visitor's next page load. The in-memory queue is capped at 1 000 events, so a tab
left open for a week cannot grow without bound.
Two failures are not retried, and both stop the SDK for the rest of the page: a 401 (the key is
unknown or revoked) and a 403 (this origin is not on the key's allowlist). Both print a line
explaining which, whether or not debug is on — retrying a wrong key forever, in every visitor's
browser, achieves nothing and is somebody else's server bill.
Nothing here can double-count. Every event's id is generated in track() and travels with it
through every retry and through storage, and the server deduplicates on it for 24 hours — so a
batch that arrives twice is counted once.
One consequence worth knowing before you deploy: a page loaded while the ingest host is unreachable, with no fresh answer in the browser's cache, collects nothing at all — the SDK does not know whether it is allowed to write to the device, so it does not. Events are held in memory and the request is retried, so a blip costs nothing; an outage that outlasts the page view costs that page view.
Feature flags
A separate import, and a separate bundle:
import analytics from '@pimasawa/web';
import { initFlags, getBool, getString } from '@pimasawa/web/flags';
analytics.init({ apiKey: 'pk_live_…' });
initFlags({ apiKey: 'pk_live_…', analytics });
if (getBool('new-checkout', false)) {
renderNewCheckout();
}
const theme = getString('checkout-theme', 'classic');The same pk_ key. The environment comes from it: a pk_test_ key evaluates your project's
test configuration and a pk_live_ key its live one, and there is deliberately no option for
it — an application cannot be in two environments at once.
Every getter is synchronous and never throws
Including before initFlags, and including with storage entirely unavailable. A flag answer
cannot wait: an application that blocked on the network to decide whether to show a button would
be an application whose first paint is somebody else's uptime.
What that costs, and it is worth being clear about it: before the first evaluation lands, every
flag answers the fallback you passed at the call site. On a returning visitor the previous
evaluation is served from localStorage immediately, so the fallback window is a cold start only.
The fallback is required, and it is the whole design
getBool('new-checkout', false); // ← that `false` is not boilerplateFlags fail closed to the call site's default. Unreachable service, timed out, a flag that has been deleted, a response that cannot be parsed — every one of them answers what you passed, and the SDK never invents a value, never keeps a cached one past its TTL, and never treats the last known answer as the current one.
That is the inverse of how the event pipeline behaves, and it is the inverse for the inverse
reason. A dropped event is data nobody ever gets back. An unevaluated flag has an answer waiting
for it in your code, and it is the one value you have actually reasoned about — while a flag that
came back true because the network was slow ships the unfinished feature to everybody.
Six getters, because a flag has a declared type
| Getter | Flag type | Answers the fallback when |
| ------------------------------- | -------------- | ----------------------------------- |
| getBool(key, fallback) | boolean | the value is not a boolean |
| getString(key, fallback) | string | the value is not a string |
| getNumber(key, fallback) | number | the value is not a finite number |
| getJSON(key, fallback) | json | the value is not an object or array |
| getStringArray(key, fallback) | string_array | any element is not a string |
| getNumberArray(key, fallback) | number_array | any element is not a finite number |
A getter whose type does not match the flag's answers the fallback and warns under debug: true.
It does not coerce: getBool on a flag somebody has since changed to a string would return
true for "off" under any coercion anybody would write, and that is a decision the SDK invented
rather than one you made.
Caching, refreshing, and how fast a kill switch travels
One request at initFlags, then a refresh on whichever of these comes first:
- the cache TTL, five minutes by default (
cacheTtl, in milliseconds); refresh_at, which the service sends when a scheduled targeting-rule window opens sooner — so a 10:00 launch lands at 10:00 rather than up to a TTL later;- coming back to a backgrounded tab with the refresh overdue;
refresh(), which you can call yourself after a login.
Composed, that is the sentence the dashboard prints on a kill switch: off for new evaluations immediately; running apps within about five minutes. The honest edge is that a device which is not running picks nothing up, and no TTL covers that — the kill reaches a backgrounded tab when it comes back.
Consent decides storage and identity, not availability
Flags are not exempt from the privacy modes, and this is the part most flag vendors skip.
| Project mode and visitor decision | Evaluated? | Identifier sent? | Written to the device? |
| --------------------------------- | --------------------- | ---------------- | ------------------------------------ |
| anonymous, or consent granted | yes | yes | yes — a stable bucket across visits |
| consent_required, undecided | yes | no | no |
| consent denied, or DNT/GPC set | no request at all | — | nothing, and anything left is erased |
| disabled project | flags still evaluate | per the above | nothing |
An undecided visitor still gets flags: the service answers a request with no identifier in full, and only a rollout strictly between 0 and 100 falls back to that flag's default — because a server-invented bucket would change the moment the real identifier arrived. Granting consent upgrades to identified evaluation on the next fetch.
flag_evaluated
Pass analytics to initFlags and the first evaluation of each flag in a session is recorded as
one event, carrying the flag key, the value served and which term of the evaluation order decided
it. Deduplicated per session, so a flag read in a render loop is one event rather than thousands.
It goes through the ordinary event pipeline, which means it is subject to the ordinary consent
gating: no analytics instance, no consent, or a project still holding — no event. Omit
analytics and none is emitted at all.
Experiments: getVariant
An experiment is a flag's sibling and the same request carries it. Which arm a visitor is in is decided by the server, deterministically, from their anonymous id — so it is the same arm on every replica, after every restart, for ever, with no per-visitor state stored anywhere.
import { getVariant } from '@pimasawa/web/flags';
const headline = getVariant('checkout-copy', 'control');The read is the exposure. Assignment is not exposure: a visitor assigned to an arm who never
reached the screen it changes is noise in the denominator, and the difference between the two is
the difference between an experiment and a coin flip with charts. So the first getVariant per
experiment per session emits one $experiment_exposure event carrying the experiment and the
variant, and the hundredth emits nothing — a variant read inside a render loop is one exposure.
An application that never asks never exposes, which is exactly the point.
A variant that changes inside a session re-exposes once, because the person genuinely saw something new.
Three things answer your fallback and emit nothing, and all three are correct:
- the visitor is not in the experiment — the audience or the traffic percentage excluded them, so they are absent from the response and your ordinary experience renders;
- there is no identifier yet, because an undecided visitor cannot be exposed and a variant served to them would re-randomise the moment their real id arrived;
- the service is unreachable, the cache has expired, or the flag client has stopped — the same fail-closed answer a flag gets.
Consent gates the event and never the answer. Your page has to render either way, so
getVariant always answers; but with consent outstanding or refused nothing is emitted and
nothing is queued — a held exposure released when somebody accepts a banner would describe a
screen they saw before agreeing to be measured. The next read after they accept is a real first
exposure.
$experiment_exposure and flag_evaluated stay two events on purpose. The first is the
statistical denominator and its deduplication rules are load-bearing; the second is diagnostic —
is this flag being asked about — and folding them would put analysis weight on an event designed
as telemetry.
If you record your own conversion event and want it stamped with the arm the visitor was served,
track takes the pair as a third argument:
analytics.track(
'purchase_completed',
{ total: 42 },
{
experiment_id: 'checkout-copy',
variant_id: headline,
},
);Both fields travel together or not at all — the server refuses an event carrying one without the other, so the SDK drops a half pair rather than letting it cost the whole event.
initFlags(options)
| Option | Default | |
| ------------------- | ---------------------------- | ------------------------------------------------------------------- |
| apiKey | — | required; the same pk_ key init takes |
| analytics | — | the instance flag_evaluated and $experiment_exposure go through |
| cacheTtl | 300000 | how long an evaluation may be served for, in milliseconds |
| appVersion | — | compared by rules over app_version; the SDK cannot know it |
| locale | navigator.language | compared by rules over locale |
| respectDoNotTrack | true | |
| debug | false | |
| host | https://flags.pimasawa.com | |
| ingestHost | https://in.pimasawa.com | where the project's privacy mode is read from |
getVariant(key, fallback) answers the arm this visitor is in — see above; the fallback is
required for the same reason every getter's is.
onChange(listener) is called with the flag keys whose value moved after a refresh, and
returns the function that stops it. It says nothing about experiments: a variant that changed is
picked up by the next getVariant, which re-exposes once. refresh() re-evaluates now and resolves when the answer has been
applied; it awaits a request already in flight rather than starting a second one.
Coming from GA4 or Amplitude
The shape is familiar and three things are genuinely different. Reading these first will save you from a migration that looks finished and is not.
From Amplitude
| Amplitude | Here |
| -------------------------------- | -------------------------------- |
| amplitude.init(key) | analytics.init({ apiKey }) |
| amplitude.track('name', props) | analytics.track('name', props) |
| amplitude.setUserId(id) | — see below |
| amplitude.reset() | analytics.reset() |
| amplitude.flush() | await analytics.flush() |
| User properties / identify | — see below |
From GA4
| GA4 | Here |
| -------------------------------------- | -------------------------------- |
| gtag('event', 'name', params) | analytics.track('name', props) |
| gtag('config', id) + automatic pages | automatic, nothing to configure |
| page_view on route change (manual) | automatic |
| Consent Mode analytics_storage | analytics.setConsent(boolean) |
The three differences that matter
There is no identify and no user id. The identity is one anonymous, resettable id, and there
is no way to attach a person to it — deliberately, and there is no MVP feature coming to add one.
If you need to segment by an account, put a non-identifying attribute on the events themselves:
plan: 'pro', org_size: '50-200'. A migration that maps setUserId(email) onto a property is a
migration that has moved personal data into an analytics store, which is the thing this SDK is
built not to do.
Event names are snake_case, and $ is reserved. GA4's recommended names mostly transfer;
Amplitude's [Amplitude] Page Viewed and anything with spaces or capitals does not. The seven
$-prefixed lifecycle names belong to the platform and you cannot add an eighth.
Properties are flat. Strings, numbers and booleans, one level. A nested object that Amplitude
would have stored is dropped here — flatten it at the call site: { 'address.city': 'Madrid' }
rather than { address: { city: 'Madrid' } }.
Two smaller ones. Data is not backfilled, so run both SDKs in parallel for as long as you need a comparable period rather than cutting over on a Monday. And the numbers will not match to the event: ad blockers block the two incumbents by name and do not block this, so this one is usually higher, which is the direction that makes people distrust the new tool rather than the old one.
Development
pnpm install
pnpm build # ESM + CJS + types into dist/
pnpm run build:fixtures # the React Router, Vue Router, Next.js and Astro apps the suite uses
pnpm test # unit, happy-dom
pnpm test:browser # Playwright, Chromium + Firefox + WebKit — needs both builds first
pnpm test:integration # against a real ingest-api; skipped if there is not one
pnpm run size # the gzipped budget
pnpm run check:package # exports map, across every resolution mode
pnpm run check:tarball # what a publish would actually put on the registryThe integration suite wants a backend, and skips loudly rather than failing when there is none. Two services now, because flags are a third deployable:
cd ../analytics-api && make up && make seed
make run-ingest # events, and the project's privacy mode
make run-flags # evaluationsThe contracts are vendored under contracts/, generated from analytics-api's own handlers and
pinned together in contracts/PINNED.json — openapi/ingest.v1.yaml, openapi/flags.v1.yaml,
and vectors/bucket.v1.json, which is the published bucketing table. The types in
src/generated/ are produced from the first two; the third is not a document and is vendored so
that the integration suite can assert the server buckets as published. This SDK deliberately
implements no hash of its own. To move the pin:
pnpm run contracts:sync # re-vendor at the pinned ref
pnpm run generate:types # regenerate src/generated/Both are needed. test/unit/contract-pin.test.ts fails if only the first was run.
Releasing
Releases are published by hand, from a laptop. .github/workflows/release.yml.disabled is the
finished OIDC pipeline and it does not run — the Actions budget is spent, and two npm constraints
found on 2026-09-04 mean it could not have published the first version anyway: a trusted publisher
cannot be configured for a package that does not exist (npm/cli#8544), and --provenance is
restricted to public source repositories while every repository here is private by ADR-0013.
.github/workflows/README.md carries the detail.
So the checks the workflow would have run are a checklist instead. Run all of it, in order, from a
clean main:
git checkout main && git pull && git status # nothing uncommitted; the tarball is what is here
pnpm install --frozen-lockfile
pnpm lint
pnpm typecheck
pnpm run test:coverage # the 90% floor is in vitest.config.ts
pnpm build && pnpm run build:fixtures
pnpm exec playwright test # all three engines
pnpm run size # both entry points, against their budgets
pnpm run check:package # publint --strict, attw across four modes
pnpm run check:tarball # dist/ plus what npm always adds, and nothing elseThen the publish itself. npm login is interactive and asks for the second factor, so nothing
long-lived is created and there is no token anywhere to leak — which is the property the OIDC
workflow was built for, kept by other means:
node -p "require('./package.json').version" # must equal the tag you are about to write
npm login # account `pimasawa`, second factor
npm publish --access public --dry-run # read the file list one last time
npm publish --access public
git tag v0.1.0 && git push origin v0.1.0 # the tag records the release; it triggers nothing--access public is not optional: the scope is private by default and a scoped package published
without it is restricted, which for this package means every consumer needs an account.
Finally, the check that no local rehearsal performs — a clean directory, no registry configuration, no credentials:
cd "$(mktemp -d)" && npm init -y >/dev/null && npm add @pimasawa/web && ls node_modules/@pimasawa/webThere is no provenance attestation on any of this, for the reason above. It costs the guarantee that a consumer can verify the tarball was built from a given commit; it does not cost anything about who can publish, which is npm's account security either way.
Changesets owns versions from here: one file per user-visible change, written in the pull request
that makes the change. pnpm exec changeset version writes the manifest and the changelog, and
that commit is what gets released by the procedure above.
Licence
Proprietary — see LICENSE. The package is on the public npm registry and the source repository is private; what you may do with it is a licence question rather than an access one.
Pimasawa is a hosted service and has no self-hosted edition; a client that cannot use this package integrates against the documented HTTP ingest API instead.
