npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

  • 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_required and disabled privacy 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/web

No 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 resolves

Then, in your own site:

pnpm add file:/path/to/analytics-sdk-web

pnpm 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>.tgz
pnpm add file:/path/to/analytics-sdk-web/pimasawa-web-<version>.tgz

Point 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 itself

Sends 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 erase

See 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 boilerplate

Flags 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 registry

The 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    # evaluations

The 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 else

Then 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/web

There 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.