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

@pixygon/analytics

v1.3.0

Published

Shared analytics SDK for Pixygon applications

Readme

@pixygon/analytics

The estate-standard analytics SDK — page views, events, conversions, web vitals, and error reporting to Discord. 18 repos depend on it, which makes it the most-used web pearl; everything below is how those repos actually wire it, not an idealised example.

Events land in POST /v1/analytics on the Pixygon API and surface in the admin Analytics tab (and the workspace Pulse roll-up).


Retrofitting it into an existing app (≈15 minutes)

Written for an agent. Do these in order; step 5 is not optional.

1. Install

npm i @pixygon/analytics@^1.3.0

1.3.0 adds consent-free basic mode — the fix for consent-gated traffic collapsing to near zero. Not published to npm yet (see "Adoption status"); until it is, ^1.2.0 is the installable floor.

>= 1.2.0 is mandatory. Before 1.2.0 the SDK minted a fresh anonymous id per event, so every page view looked like a new visitor and unique-visitor counts were fiction. verify.mjs fails the repo if the installed version is older.

2. Know your ids

| Value | Where it comes from | |---|---| | projectId | the MongoDB ObjectId in this repo's .pixygon.json ({"projectId": "69ee…"}) | | appName | the human name, used in the Discord error reports (Tastebud, Kartograf) |

The server does resolve a slug (resolveProjectIdProject.slug or kebab-cased title), so projectId: 'kikortet-no' works if a project with that exact slug exists. It fails silently if not — the events are ingested and attributed to nothing. Always use the ObjectId. If the repo has no .pixygon.json, create one first.

3. Mount the provider

The React path (what every repo uses):

// src/main.tsx
import { AnalyticsProvider, AnalyticsErrorBoundary } from '@pixygon/analytics/react'

const analyticsConfig = {
  projectId: '69ee9b3f7a116f8ba90d0890',            // from .pixygon.json
  appName: 'Tastebud',
  endpoint: `${import.meta.env.VITE_API_URL || 'https://api.pixygon.com/v1'}/analytics`,
}

<AnalyticsProvider config={analyticsConfig}>
  <AnalyticsErrorBoundary>
    <App />
  </AnalyticsErrorBoundary>
</AnalyticsProvider>

endpoint is optional — the SDK default is already https://api.pixygon.com/v1/analytics. Only set it when you need the env-var escape hatch above; never point it at localhost with no fallback.

Non-React (script/vanilla) path:

import PixygonAnalytics from '@pixygon/analytics'
PixygonAnalytics.init({ projectId: '…', appName: '…' })

init() throws if projectId is missing.

4. Track page views on route change

The provider fires session_start and one initial page_view; SPA route changes need the hook:

import { usePageTracking } from '@pixygon/analytics/react'

function App() {
  usePageTracking()   // reads window.location.pathname; pass a path to override
  return <Routes>…</Routes>
}

5. Gate it — consent, bots, and dev traffic

This is the step that gets skipped and then poisons the numbers. Since the 2026-08 GDPR pass, every Pixygon frontend gates analytics. Use the package's own gate — it handles all three states (see the next section for why three):

import { AnalyticsGate } from '@pixygon/analytics/react'

const consent = useSyncExternalStore(subscribeConsent, getConsent)  // 'accepted' | 'declined' | null

<AnalyticsGate
  consent={consent}
  config={analyticsConfig}
  skip={isLikelyBot() || isDevTraffic()}
>
  <App />
</AnalyticsGate>

Do not mount <AnalyticsProvider> yourself when using the gate — the gate mounts it (with <AnalyticsErrorBoundary> inside) once consent is accepted. Gating only the autoTrack flags does nothing: session_start fires from the SDK constructor and page_view from usePageTracking, both independent of autoTrack. The only real off switch is not mounting the provider.

Every tracking hook no-ops without provider context (isReady === false), so the app runs identically when analytics is gated off.

Consequence to expect: fully-attributed traffic numbers drop after adding the consent gate. That is by design, not a regression.

6. Instrument what matters

import { useTrackEvent, useTrackConversion, useIdentify } from '@pixygon/analytics/react'

const track = useTrackEvent()
track('list_created', { itemCount: 12 })

const identify = useIdentify()
identify(user._id, { plan: 'plus' })          // call right after login

const convert = useTrackConversion()
convert({ type: 'signup', value: 0 })          // signup / purchase / upgrade

Errors are reported automatically (window.onerror, unhandledrejection, and AnalyticsErrorBoundary) to POST /v1/errors/report, which pings Discord and feeds the nightly error-triage autopilot. For manual catches:

import { reportError } from '@pixygon/analytics'
reportError(err, { userId })

7. Verify it worked

node node_modules/@pixygon/analytics/verify.mjs
# or, from Dyson's registry:
pearl verify analytics

Static check — installed version, provider/init present, real non-placeholder projectId matching .pixygon.json, endpoint pointing at /v1/analytics, and whether consent-free basic mode is wired. Exit 0 pass, 1 fail.

The basic-mode line is informational: it never fails a repo that hasn't adopted basic mode, but it does warn when it finds a local copy of the module still in place, or a two-state consent gate that leaves undecided visitors unmeasured.

Add a live probe of the ingest endpoint:

node node_modules/@pixygon/analytics/verify.mjs --url=https://api.pixygon.com/v1/analytics

That POSTs one real pearl_verify event (it will show up in the project's custom events — that is the point) and asserts {success:true, processed:1}.

What verify can and cannot prove. It proves installed + configured, and with --url that the endpoint is alive. It cannot prove events actually leave a real browser: the consent gate, bot gate, CSP, and ad-blockers all sit between this config and the server. The only end-to-end proof is opening the site in a real browser, accepting cookies, and watching the visit appear in the admin Analytics tab.


Consent-free basic mode

The problem it solves

Cookie-consent gating cut measured traffic to near zero across the estate. The reason is not that people decline — it is that most visitors never tap either banner button. In-app browsers (Facebook's above all, which is the bulk of paid-social traffic) are the worst case. A two-state gate — accepted vs everything-else — therefore measures the undecided majority as if they had never existed. Lønnlyst showed 371 real sessions as ~0 conversions.

Basic mode is the third state: the maximum measurement that is lawful with no consent at all.

skip (bot/dev)   → nothing
consent === null → BASIC mode      ← the state that was missing
'accepted'       → full SDK (basic stops automatically)
'declined'       → nothing

The exact legal line

Basic mode needs no consent because it stays on the right side of two separate rules, and it must keep doing both:

  1. ePrivacy Directive art. 5(3) (in Norway: ekomloven § 2-7b) requires consent to store information on, or gain access to information stored in, a user's terminal equipment. Basic mode writes and reads nothing — no cookies, no localStorage, no sessionStorage, no IndexedDB, no cache probing. The consent requirement is therefore not triggered at all. This is the same basis Plausible/Fathom-style "cookieless analytics" run on.
  2. GDPR still applies to whatever is processed. Basic mode's defence is that it processes no personal data and creates no identifier that could single anyone out: the session id is a module variable regenerated on every page load, so it cannot follow a visitor across visits or be joined to any other dataset. There is no user id, no fingerprint, no full referrer, no UTM.

Where the line is drawn — do not cross these:

| Allowed in basic mode | NOT allowed (needs consent) | |---|---| | page views (origin + pathname) | query strings, hashes, full URLs | | explicit funnel counters (conversion_type) | conversion value, order ids, cart contents | | referrer host (l.facebook.com) | full referrer URL | | a per-page-load ephemeral session id | any persisted or derivable id | | — | UTM / campaign parameters | | — | screen size, user agent, language, device type | | — | user ids, emails, logged-in state | | — | clicks, scroll depth, time-on-page, errors |

Two things that are not optional even though consent is:

  • GDPR art. 13 transparency still applies. You must disclose basic mode in the privacy policy. Running it silently is not the deal.
  • IP addresses reach the server as they do for any HTTP request. The Pixygon ingest must not store raw IPs against these events. That is a server-side property; this package cannot enforce it.

This is engineering guidance written from how the estate operates, not legal advice. If a specific site has a DPA or a supervisory-authority commitment that says otherwise, that wins.

Why it is a separate module, not a flag on the SDK

core.ts writes localStorage from its constructor path — anonymous id, session id, UTM params, and the event queue. A consent: 'basic' flag threaded through that code could regress into writing storage with one careless edit, and the regression would be invisible. src/basic.ts is instead a standalone ~120-line module you can audit end to end: it contains no storage API at all, which is a property you can grep for and which npm run test:basic asserts at runtime.

Wiring it (React — the paved path)

Replace your hand-rolled gate with <AnalyticsGate>; it is the whole thing:

import { AnalyticsGate } from '@pixygon/analytics/react'

const consent = useSyncExternalStore(subscribeConsent, getConsent)

<AnalyticsGate
  consent={consent}                                  // 'accepted' | 'declined' | null
  config={analyticsConfig}                           // same config as AnalyticsProvider
  skip={isLikelyBot() || isDevTraffic()}
>
  <App />
</AnalyticsGate>

Then nothing else changes: usePageTracking() and useTrackConversion() already fall through to basic mode when the consented provider is not mounted, so your existing route tracking and conversion calls keep working in the pre-consent state with no new call sites. Only data.type crosses over on a conversion — value is deliberately dropped.

Your consent store must be three-state. The single most common mistake is getConsent() returning 'declined' for "no answer yet"; that collapses basic mode back to nothing:

export const getConsent = (): ConsentChoice => {
  try { return (localStorage.getItem(KEY) as ConsentChoice) ?? null } catch { return null }
}                                                   // ← null, NOT 'declined'

Wiring it (imperative / non-React)

import { init, startBasic, stopBasic, basicPageView, basicConversion } from '@pixygon/analytics'

const consent = localStorage.getItem('cookie_consent')       // 'accepted' | 'declined' | null
if (consent === 'accepted') init({ projectId, appName })     // init() calls stopBasic() itself
else if (consent !== 'declined') startBasic({ projectId })   // undecided → measure anyway

// on the banner's Accept:  init(...)        — handover is automatic
// on the banner's Decline: stopBasic()

The accept-handover is enforced inside init(), not in your gate: the two modes can never both run, so nobody can double-count by wiring the gate wrong.

REQUIRED copy

Adopting basic mode obliges you to change two pieces of user-facing text. This is not optional polish — it is the transparency half of the legal basis.

The copy below matches the recommended behaviour — the one <AnalyticsGate> implements: basic mode runs only before a choice is made, and a decline stops everything. If you deviate from that, the copy must deviate with it.

Cookie banner — the banner must disclose that something is counted before the visitor answers. Silence there is the part that is not defensible:

Norwegian (estate default): "Vi bruker informasjonskapsler til statistikk, slik at vi kan forbedre nettstedet. Inntil du velger, teller vi kun anonyme sidevisninger — uten å lagre noe på enheten din og uten å samle inn personopplysninger. [Godta] [Avslå]"

English: "We use cookies for analytics so we can improve the site. Until you choose, we count anonymous page views only — without storing anything on your device and without collecting personal data. [Accept] [Decline]"

Both buttons must be equally prominent — a dark-patterned decline undermines the consent you collect for the full SDK.

Privacy policy — add a paragraph of this shape:

Statistikk uten informasjonskapsler. Før du har tatt et valg om informasjonskapsler, teller vi kun anonyme sidevisninger og et fåtall hendelser (for eksempel «registrering fullført»). Vi lagrer ingenting på enheten din, bruker ingen informasjonskapsler, og oppretter ingen identifikator som kan følge deg mellom besøk eller mellom nettsteder. Vi registrerer hvilken side som ble besøkt og hvilket nettsted du kom fra (kun domenenavnet) — ikke adressen i sin helhet. Dette er ikke personopplysninger og krever derfor ikke samtykke. Velger du «Avslå», stopper også denne tellingen.

⚠ If you deliberately keep basic mode running after a decline (allowed by ePrivacy, but not what <AnalyticsGate> does and not recommended — a decline is a clear signal), then both texts must say so explicitly: change "Inntil du velger" to "Uansett hva du velger" and drop the final sentence of the privacy paragraph. Shipping the recommended copy while running the other behaviour is the one combination that is genuinely indefensible.

Reading the data

Basic events are tagged consent_mode: 'basic' and carry session_id: "anon_…". Split on that tag when reporting:

  • Basic + full together = the true traffic shape. Use this for trends.
  • Full only = the attributable slice (UTM, returning visitors, user ids).
  • Do not compute unique visitors, bounce rate, or session duration from basic events. One page-load is one session by construction, so uniques are inflated and every basic session looks like a bounce. That limitation is the price of not needing consent.

Proving the invariants

npm run test:basic          # from the package

Fakes a browser and asserts, at runtime: nothing touches localStorage/sessionStorage/document.cookie, keepalive is set, the referrer is host-only, the URL carries no query/hash, no UTM or fingerprint fields appear, the session id is the ephemeral anon_ form, and init() stops basic mode so the two can never double-count. 19 checks.

Adoption status (2026-08-07)

lonnlyst.no and Tastebud currently carry local copies of this module (src/utils/basicAnalytics.ts, ~85 lines each, plus a ~60-line hand-rolled gate). Those copies came first and this package version was upstreamed from them.

This package version is NOT published to npm yet (no publish token in the session that built it). Until @pixygon/[email protected] is on the registry, apps keep their local copy. Once it is published, each app should:

  1. npm i @pixygon/analytics@^1.3.0
  2. delete src/utils/basicAnalytics.ts and the hand-rolled gate component
  3. mount <AnalyticsGate> per above
  4. node node_modules/@pixygon/analytics/verify.mjs — the basic-mode line should read "wired from the package"

verify.mjs reports adoption as a recommendation and never fails a repo for not having it; it does warn when it finds a local copy still in place.


API

| Export | From | Use | |---|---|---| | init(config) | @pixygon/analytics | imperative init (throws without projectId) | | track / page / identify | @pixygon/analytics | module-level helpers (no-op before init) | | trackConversion / trackAIGeneration | @pixygon/analytics | funnel + AI-cost events | | reportError(err, ctx) | @pixygon/analytics | manual Discord error report | | flush() / getSession() / destroy() | @pixygon/analytics | batching + session control | | startBasic({projectId, endpoint?}) | both | consent-free mode ON (see above) | | stopBasic() / isBasicActive() | both | consent-free mode OFF / state | | basicPageView(path) / basicConversion(type) | both | the only two basic-mode events | | <AnalyticsGate consent config skip> | @pixygon/analytics/react | the three-state gate — use this | | <AnalyticsProvider config> | @pixygon/analytics/react | the raw mount (the gate wraps it) | | <AnalyticsErrorBoundary> | @pixygon/analytics/react | React errors → analytics + Discord | | usePageTracking() | @pixygon/analytics/react | SPA route → page_view | | useTrackEvent() / useTrackConversion() / useIdentify() | @pixygon/analytics/react | the hooks you'll actually use | | useAnalytics() / useSession() / useFlush() | @pixygon/analytics/react | state + control | | EVENTS, EVENT_PROPERTIES | both | the canonical event/property names |

Config

{
  projectId: string          // REQUIRED — .pixygon.json ObjectId
  appName?: string           // shown in Discord error reports
  endpoint?: string          // default https://api.pixygon.com/v1/analytics
  autoTrack?: boolean        // default true
  trackClicks?: boolean      // default true
  trackTimeSpent?: boolean   // default true
  trackErrors?: boolean      // default true
  trackOutboundLinks?: boolean
  trackPerformance?: boolean
  batchSize?: number         // default 10
  batchTimeout?: number      // default 5000 ms
  debug?: boolean
  storagePrefix?: string     // default px_analytics_<projectId>
}

Gotchas

  • autoTrack: false does not silence the SDK. session_start comes from the constructor and page_view from usePageTracking. To silence it, don't mount the provider.
  • A slug projectId is fragile. Checked 2026-08-07: every slug in use across the estate (kikortet-no, studiohemstad, cvfilm, norsats-no, villgress-no, recurify, solarhelp, pixygon-mobil, PixygonSupport) currently does resolve to the right project. But resolution goes through Project.slug / kebab-cased Project.title, so renaming a project in the admin silently orphans that app's analytics — with no error anywhere. See step 2; use the ObjectId.
  • session_end rarely fires, so "bounce rate" derived from it was fake 0% for a long time — don't build reporting on it.
  • Two providers, one page = double counting. Mount exactly one.
  • Vendored copies exist. A few older repos (e.g. kikortet.no/src/sdk/analytics) still carry a hand-rolled SDK next to the package. Delete the vendored copy when you retrofit; two SDKs means two sessions per visitor.
  • A two-state consent gate flatlines your numbers. consent !== 'accepted' lumps the undecided majority in with the decliners. Use the three-state <AnalyticsGate>; make sure your consent store returns null (not 'declined') for "hasn't answered".
  • Basic mode can't do uniques, bounce, or session duration. Every basic session is one page-load by construction. Reporting built on those fields will be wrong; split on consent_mode first.
  • The dev's own laptop is traffic too. Without isDevTraffic(), every npm run dev writes into the live project (that's the "276 visits / 0 conversions / 4.2h session" shape).

Publishing

npm run build && npm publish --access public