@pixygon/analytics
v1.3.0
Published
Shared analytics SDK for Pixygon applications
Maintainers
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.01.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 (resolveProjectId → Project.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 / upgradeErrors 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 analyticsStatic 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/analyticsThat 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' → nothingThe 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:
- 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, nosessionStorage, 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. - 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 packageFakes 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:
npm i @pixygon/analytics@^1.3.0- delete
src/utils/basicAnalytics.tsand the hand-rolled gate component - mount
<AnalyticsGate>per above 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: falsedoes not silence the SDK.session_startcomes from the constructor andpage_viewfromusePageTracking. To silence it, don't mount the provider.- A slug
projectIdis 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 throughProject.slug/ kebab-casedProject.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_endrarely 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 returnsnull(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_modefirst. - The dev's own laptop is traffic too. Without
isDevTraffic(), everynpm run devwrites into the live project (that's the "276 visits / 0 conversions / 4.2h session" shape).
Publishing
npm run build && npm publish --access public