{"_id":"@pixygon/analytics","name":"@pixygon/analytics","description":"Shared analytics SDK for Pixygon applications","license":"MIT","homepage":"https://github.com/pixygon/pixygon-packages#readme","repository":{"url":"git+https://github.com/pixygon/pixygon-packages.git","type":"git"},"keywords":["analytics","tracking","pixygon","web-vitals","error-tracking"],"author":{"name":"Pixygon"},"bugs":{"url":"https://github.com/pixygon/pixygon-packages/issues"},"dist-tags":{"latest":"1.3.2"},"versions":{"1.0.0":{"name":"@pixygon/analytics","version":"1.0.0","keywords":["analytics","tracking","pixygon","web-vitals","error-tracking"],"author":{"name":"Pixygon"},"license":"MIT","_id":"@pixygon/analytics@1.0.0","maintainers":[{"name":"imakestupidgames","email":"anders@pixygon.io"}],"homepage":"https://github.com/pixygon/pixygon-packages#readme","bugs":{"url":"https://github.com/pixygon/pixygon-packages/issues"},"dist":{"shasum":"08efb0cfece884a8103700a1c603c95cf5a3cfb0","unpackedSize":236727,"fileCount":20,"integrity":"sha512-au6VFAGwRqTaMZNPLC4tWo/GxNOpx7DNTSZ4EWqpqYiBqUXV1bn76Xxn7XLicPNkntfvNqKeb9BJVWjAOK0L4Q==","tarball":"https://ppm.pixygon.io/@pixygon/analytics/-/analytics-1.0.0.tgz"},"main":"dist/index.js","types":"dist/index.d.ts","module":"dist/index.mjs","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.js"},"./react":{"types":"./dist/react.d.ts","import":"./dist/react.mjs","require":"./dist/react.js"}},"gitHead":"849d9d2c71b609a4816ce9175ee6e3940064c2fb","scripts":{"dev":"tsup --watch","lint":"tsc --noEmit","build":"tsup","typecheck":"tsc --noEmit"},"_npmUser":{"name":"imakestupidgames","email":"anders@pixygon.io"},"repository":{"url":"git+https://github.com/pixygon/pixygon-packages.git","type":"git"},"_npmVersion":"11.7.0","description":"Shared analytics SDK for Pixygon applications","directories":{},"_nodeVersion":"25.3.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","react":"^19.0.0","typescript":"^5.3.0","@types/react":"^19.0.0"},"peerDependencies":{"react":">=17.0.0"},"peerDependenciesMeta":{"react":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/analytics_1.0.0_1769793400406_0.9246996372726874","host":"s3://npm-registry-packages-npm-production"}},"1.1.0":{"name":"@pixygon/analytics","version":"1.1.0","keywords":["analytics","tracking","pixygon","web-vitals","error-tracking"],"author":{"name":"Pixygon"},"license":"MIT","_id":"@pixygon/analytics@1.1.0","maintainers":[{"name":"imakestupidgames","email":"anders@pixygon.io"}],"homepage":"https://github.com/pixygon/pixygon-packages#readme","bugs":{"url":"https://github.com/pixygon/pixygon-packages/issues"},"dist":{"shasum":"71726655a44569f99ed1e0d3ee1b1585f036be6f","unpackedSize":209082,"fileCount":14,"integrity":"sha512-wm9Hp8JQ30u3k1jjZQBBNcvl1YdzHBM7Cz0f+1ZtGHMnCOrnHPw8asEKbVneyIonYuqxLo1QO6UVjp4iD6JKKg==","tarball":"https://ppm.pixygon.io/@pixygon/analytics/-/analytics-1.1.0.tgz"},"main":"dist/index.js","types":"dist/index.d.ts","module":"dist/index.mjs","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.js"},"./react":{"types":"./dist/react.d.ts","import":"./dist/react.mjs","require":"./dist/react.js"}},"gitHead":"8b026ff827029f14d9d3fd9fea9fdc8373b4764f","scripts":{"dev":"tsup --watch","lint":"tsc --noEmit","build":"tsup","typecheck":"tsc --noEmit"},"_npmUser":{"name":"imakestupidgames","email":"anders@pixygon.io"},"repository":{"url":"git+https://github.com/pixygon/pixygon-packages.git","type":"git"},"_npmVersion":"11.12.0","description":"Shared analytics SDK for Pixygon applications","directories":{},"_nodeVersion":"25.8.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","react":"^19.0.0","typescript":"^5.3.0","@types/react":"^19.0.0"},"peerDependencies":{"react":">=17.0.0"},"peerDependenciesMeta":{"react":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/analytics_1.1.0_1777202487181_0.2821793861455799","host":"s3://npm-registry-packages-npm-production"}},"1.1.1":{"name":"@pixygon/analytics","version":"1.1.1","keywords":["analytics","tracking","pixygon","web-vitals","error-tracking"],"author":{"name":"Pixygon"},"license":"MIT","_id":"@pixygon/analytics@1.1.1","maintainers":[{"name":"imakestupidgames","email":"anders@pixygon.io"}],"homepage":"https://github.com/pixygon/pixygon-packages#readme","bugs":{"url":"https://github.com/pixygon/pixygon-packages/issues"},"dist":{"shasum":"ef4a7849bedbbbb6796e444815bbd0b67169778a","unpackedSize":245610,"fileCount":21,"integrity":"sha512-PuAFzkO3ArUQr9yKZXFWm2PI0JG899cFREkZY94g2X6vT/u/+h+0qwrnkviZ/P6XyH94RKJkE3oc9AJqRdBmCg==","tarball":"https://ppm.pixygon.io/@pixygon/analytics/-/analytics-1.1.1.tgz"},"main":"dist/index.js","types":"dist/index.d.ts","module":"dist/index.mjs","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.js"},"./react":{"types":"./dist/react.d.ts","import":"./dist/react.mjs","require":"./dist/react.js"}},"gitHead":"a65fbb568b465da64b56e1dda5bd2a7874a508aa","scripts":{"dev":"tsup --watch","lint":"tsc --noEmit","build":"tsup","typecheck":"tsc --noEmit"},"_npmUser":{"name":"imakestupidgames","email":"anders@pixygon.io"},"repository":{"url":"git+https://github.com/pixygon/pixygon-packages.git","type":"git"},"_npmVersion":"11.12.0","description":"Shared analytics SDK for Pixygon applications","directories":{},"_nodeVersion":"25.8.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","react":"^19.0.0","typescript":"^5.3.0","@types/react":"^19.0.0"},"peerDependencies":{"react":">=17.0.0"},"peerDependenciesMeta":{"react":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/analytics_1.1.1_1777207787670_0.6657480992869862","host":"s3://npm-registry-packages-npm-production"}},"1.2.0":{"name":"@pixygon/analytics","version":"1.2.0","keywords":["analytics","tracking","pixygon","web-vitals","error-tracking"],"author":{"name":"Pixygon"},"license":"MIT","_id":"@pixygon/analytics@1.2.0","maintainers":[{"name":"imakestupidgames","email":"anders@pixygon.io"}],"homepage":"https://github.com/pixygon/pixygon-packages#readme","bugs":{"url":"https://github.com/pixygon/pixygon-packages/issues"},"dist":{"shasum":"38c9adb55869865799a3216198a94c66b7d431e6","unpackedSize":251109,"fileCount":21,"integrity":"sha512-6VyZDMygu+qN5ym7GxjhUrytdU/gaU4bA9XgFjDnZAH5kNwfEvci/wLIIZi/v3kgmIYlEnYdBlIyGGkQoJuG6w==","tarball":"https://ppm.pixygon.io/@pixygon/analytics/-/analytics-1.2.0.tgz"},"main":"dist/index.js","types":"dist/index.d.ts","module":"dist/index.mjs","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.js"},"./react":{"types":"./dist/react.d.ts","import":"./dist/react.mjs","require":"./dist/react.js"}},"gitHead":"2ccb589000d4f655526869595b0025302cd60440","scripts":{"dev":"tsup --watch","lint":"tsc --noEmit","build":"tsup","typecheck":"tsc --noEmit"},"_npmUser":{"name":"imakestupidgames","email":"anders@pixygon.io"},"repository":{"url":"git+https://github.com/pixygon/pixygon-packages.git","type":"git"},"_npmVersion":"11.16.0","description":"Shared analytics SDK for Pixygon applications","directories":{},"_nodeVersion":"26.2.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","react":"^19.0.0","typescript":"^5.3.0","@types/react":"^19.0.0"},"peerDependencies":{"react":">=17.0.0"},"peerDependenciesMeta":{"react":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/analytics_1.2.0_1783902622784_0.15055810935988112","host":"s3://npm-registry-packages-npm-production"}},"1.3.0":{"name":"@pixygon/analytics","version":"1.3.0","keywords":["analytics","tracking","pixygon","web-vitals","error-tracking"],"author":{"name":"Pixygon"},"license":"MIT","_id":"@pixygon/analytics@1.3.0","maintainers":[{"name":"imakestupidgames","email":"anders@pixygon.io"}],"homepage":"https://github.com/pixygon/pixygon-packages#readme","bugs":{"url":"https://github.com/pixygon/pixygon-packages/issues"},"bin":{"pixygon-verify-analytics":"verify.mjs"},"dist":{"shasum":"686b7e1c5ae6b092d5ae00054a9984b1cf3ffe99","unpackedSize":329279,"fileCount":24,"integrity":"sha512-tAT3EvOJ+7iIdMTqLjziAqYLEJYRIrdqEJq74ULNEUXd99xvRiZOXXg91/LrdT6488jRqlWy+sHbxNpN0Q+nlA==","tarball":"https://ppm.pixygon.io/@pixygon/analytics/-/analytics-1.3.0.tgz"},"main":"dist/index.js","types":"dist/index.d.ts","module":"dist/index.mjs","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.js"},"./react":{"types":"./dist/react.d.ts","import":"./dist/react.mjs","require":"./dist/react.js"}},"gitHead":"dec2a09e1b134a971a5a344cfc5f5da10a6a43b2","scripts":{"dev":"tsup --watch","lint":"tsc --noEmit","build":"tsup","verify":"node verify.mjs","typecheck":"tsc --noEmit","test:basic":"node test-basic.mjs"},"_npmUser":{"name":"imakestupidgames","email":"anders@pixygon.io"},"repository":{"url":"git+https://github.com/pixygon/pixygon-packages.git","type":"git"},"_npmVersion":"11.16.0","description":"Shared analytics SDK for Pixygon applications","directories":{},"_nodeVersion":"26.2.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","react":"^19.0.0","typescript":"^5.3.0","@types/react":"^19.0.0"},"peerDependencies":{"react":">=17.0.0"},"peerDependenciesMeta":{"react":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/analytics_1.3.0_1786212541647_0.26138569104458464","host":"s3://npm-registry-packages-npm-production"}},"1.3.1":{"name":"@pixygon/analytics","version":"1.3.1","keywords":["analytics","tracking","pixygon","web-vitals","error-tracking"],"author":{"name":"Pixygon"},"license":"MIT","_id":"@pixygon/analytics@1.3.1","maintainers":[{"name":"imakestupidgames","email":"anders@pixygon.io"}],"homepage":"https://github.com/pixygon/pixygon-packages#readme","bugs":{"url":"https://github.com/pixygon/pixygon-packages/issues"},"bin":{"pixygon-verify-analytics":"verify.mjs"},"dist":{"shasum":"cb0c6be0c460ed769cc4db5728a996c082bab74b","unpackedSize":279231,"fileCount":18,"integrity":"sha512-rgeYWdRSxlhe+mPxYiVjF416VWtaajS5onFVSSr9E3LR+ZMdas9GJztyYXGh6dVgMJGxPWQUX/LbExckZ8L4rw==","tarball":"https://ppm.pixygon.io/@pixygon/analytics/-/analytics-1.3.1.tgz"},"main":"dist/index.js","types":"dist/index.d.ts","module":"dist/index.mjs","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.js"},"./react":{"types":"./dist/react.d.ts","import":"./dist/react.mjs","require":"./dist/react.js"}},"gitHead":"f21948aaac9580ea9587f259cd111e412fa42255","scripts":{"dev":"tsup --watch","lint":"tsc --noEmit","build":"tsup","verify":"node verify.mjs","typecheck":"tsc --noEmit","test:basic":"node test-basic.mjs"},"_npmUser":{"name":"imakestupidgames","email":"anders@pixygon.io"},"repository":{"url":"git+https://github.com/pixygon/pixygon-packages.git","type":"git"},"_npmVersion":"12.0.2","description":"Shared analytics SDK for Pixygon applications","directories":{},"_nodeVersion":"26.8.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","react":"^19.0.0","typescript":"^5.3.0","@types/react":"^19.0.0"},"peerDependencies":{"react":">=17.0.0"},"peerDependenciesMeta":{"react":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/analytics_1.3.1_1788439974439_0.6317255026613897","host":"s3://npm-registry-packages-npm-production"},"deprecated":"published without type declarations — use 1.3.2"},"1.3.2":{"name":"@pixygon/analytics","version":"1.3.2","keywords":["analytics","tracking","pixygon","web-vitals","error-tracking"],"author":{"name":"Pixygon"},"license":"MIT","_id":"@pixygon/analytics@1.3.2","maintainers":[{"name":"imakestupidgames","email":"anders@pixygon.io"}],"homepage":"https://github.com/pixygon/pixygon-packages#readme","bugs":{"url":"https://github.com/pixygon/pixygon-packages/issues"},"bin":{"pixygon-verify-analytics":"verify.mjs"},"dist":{"shasum":"e358ac8f2d66cbb29160d03ff910b033b03e3527","unpackedSize":331845,"fileCount":24,"integrity":"sha512-UC3w2Jd4NPlDlpLwv6n/ZjENxM+/bYbLoSBU1waqubkg+ZP/9Co+gEltS/+Nx/HHwqdtIvvbMKYjOJKc/HPb3Q==","tarball":"https://ppm.pixygon.io/@pixygon/analytics/-/analytics-1.3.2.tgz"},"main":"dist/index.js","types":"dist/index.d.ts","module":"dist/index.mjs","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.js"},"./react":{"types":"./dist/react.d.ts","import":"./dist/react.mjs","require":"./dist/react.js"}},"gitHead":"f21948aaac9580ea9587f259cd111e412fa42255","scripts":{"dev":"tsup --watch","lint":"tsc --noEmit","build":"tsup","verify":"node verify.mjs","typecheck":"tsc --noEmit","test:basic":"node test-basic.mjs"},"_npmUser":{"name":"imakestupidgames","email":"anders@pixygon.io"},"repository":{"url":"git+https://github.com/pixygon/pixygon-packages.git","type":"git"},"_npmVersion":"12.0.2","description":"Shared analytics SDK for Pixygon applications","directories":{},"_nodeVersion":"26.8.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.0.0","react":"^19.0.0","typescript":"^5.3.0","@types/react":"^19.0.0"},"peerDependencies":{"react":">=17.0.0"},"peerDependenciesMeta":{"react":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/analytics_1.3.2_1788440019152_0.35160825121722405","host":"s3://npm-registry-packages-npm-production"}}},"time":{"1.0.0":"2026-01-30T17:16:40.570Z","modified":"2026-09-30T23:30:24.910Z","created":"2026-01-30T17:16:40.570Z","1.1.0":"2026-04-26T11:21:27.340Z","1.1.1":"2026-04-26T12:49:47.823Z","1.2.0":"2026-07-13T00:30:22.922Z","1.3.0":"2026-08-08T18:09:01.788Z","1.3.1":"2026-09-03T12:52:54.607Z","1.3.2":"2026-09-03T12:53:39.281Z"},"readme":"# @pixygon/analytics\n\nThe estate-standard analytics SDK — page views, events, conversions, web\nvitals, and error reporting to Discord. **18 repos** depend on it, which makes\nit the most-used web pearl; everything below is how those repos actually wire\nit, not an idealised example.\n\nEvents land in `POST /v1/analytics` on the Pixygon API and surface in the\nadmin **Analytics** tab (and the workspace **Pulse** roll-up).\n\n---\n\n## Retrofitting it into an existing app (≈15 minutes)\n\nWritten for an agent. Do these in order; step 5 is not optional.\n\n### 1. Install\n\n```bash\nnpm i @pixygon/analytics@^1.3.0\n```\n\n`1.3.0` adds [consent-free basic mode](#consent-free-basic-mode) — the fix for\nconsent-gated traffic collapsing to near zero. **Not published to npm yet** (see\n\"Adoption status\"); until it is, `^1.2.0` is the installable floor.\n\n⚠ **`>= 1.2.0` is mandatory.** Before 1.2.0 the SDK minted a fresh anonymous\nid per event, so every page view looked like a new visitor and unique-visitor\ncounts were fiction. `verify.mjs` fails the repo if the installed version is\nolder.\n\n### 2. Know your ids\n\n| Value | Where it comes from |\n|---|---|\n| `projectId` | the **MongoDB ObjectId** in this repo's `.pixygon.json` (`{\"projectId\": \"69ee…\"}`) |\n| `appName` | the human name, used in the Discord error reports (`Tastebud`, `Kartograf`) |\n\nThe server *does* resolve a slug (`resolveProjectId` → `Project.slug` or\nkebab-cased title), so `projectId: 'kikortet-no'` works **if** a project with\nthat exact slug exists. It fails silently if not — the events are ingested and\nattributed to nothing. **Always use the ObjectId.** If the repo has no\n`.pixygon.json`, create one first.\n\n### 3. Mount the provider\n\nThe React path (what every repo uses):\n\n```tsx\n// src/main.tsx\nimport { AnalyticsProvider, AnalyticsErrorBoundary } from '@pixygon/analytics/react'\n\nconst analyticsConfig = {\n  projectId: '69ee9b3f7a116f8ba90d0890',            // from .pixygon.json\n  appName: 'Tastebud',\n  endpoint: `${import.meta.env.VITE_API_URL || 'https://api.pixygon.com/v1'}/analytics`,\n}\n\n<AnalyticsProvider config={analyticsConfig}>\n  <AnalyticsErrorBoundary>\n    <App />\n  </AnalyticsErrorBoundary>\n</AnalyticsProvider>\n```\n\n`endpoint` is optional — the SDK default is already\n`https://api.pixygon.com/v1/analytics`. Only set it when you need the\nenv-var escape hatch above; never point it at localhost with no fallback.\n\nNon-React (script/vanilla) path:\n\n```ts\nimport PixygonAnalytics from '@pixygon/analytics'\nPixygonAnalytics.init({ projectId: '…', appName: '…' })\n```\n\n`init()` **throws** if `projectId` is missing.\n\n### 4. Track page views on route change\n\nThe provider fires `session_start` and one initial `page_view`; SPA route\nchanges need the hook:\n\n```tsx\nimport { usePageTracking } from '@pixygon/analytics/react'\n\nfunction App() {\n  usePageTracking()   // reads window.location.pathname; pass a path to override\n  return <Routes>…</Routes>\n}\n```\n\n### 5. Gate it — consent, bots, and dev traffic\n\nThis is the step that gets skipped and then poisons the numbers. Since the\n2026-08 GDPR pass, **every** Pixygon frontend gates analytics. Use the\npackage's own gate — it handles all three states (see the next section for\n*why* three):\n\n```tsx\nimport { AnalyticsGate } from '@pixygon/analytics/react'\n\nconst consent = useSyncExternalStore(subscribeConsent, getConsent)  // 'accepted' | 'declined' | null\n\n<AnalyticsGate\n  consent={consent}\n  config={analyticsConfig}\n  skip={isLikelyBot() || isDevTraffic()}\n>\n  <App />\n</AnalyticsGate>\n```\n\nDo **not** mount `<AnalyticsProvider>` yourself when using the gate — the gate\nmounts it (with `<AnalyticsErrorBoundary>` inside) once consent is `accepted`.\nGating only the `autoTrack` flags does nothing: `session_start` fires from the\nSDK constructor and `page_view` from `usePageTracking`, both independent of\n`autoTrack`. The only real off switch is not mounting the provider.\n\nEvery tracking hook no-ops without provider context (`isReady === false`), so\nthe app runs identically when analytics is gated off.\n\n**Consequence to expect:** fully-attributed traffic numbers drop after adding\nthe consent gate. That is by design, not a regression.\n\n### 6. Instrument what matters\n\n```tsx\nimport { useTrackEvent, useTrackConversion, useIdentify } from '@pixygon/analytics/react'\n\nconst track = useTrackEvent()\ntrack('list_created', { itemCount: 12 })\n\nconst identify = useIdentify()\nidentify(user._id, { plan: 'plus' })          // call right after login\n\nconst convert = useTrackConversion()\nconvert({ type: 'signup', value: 0 })          // signup / purchase / upgrade\n```\n\nErrors are reported automatically (`window.onerror`, `unhandledrejection`,\nand `AnalyticsErrorBoundary`) to `POST /v1/errors/report`, which pings\nDiscord and feeds the nightly error-triage autopilot. For manual catches:\n\n```ts\nimport { reportError } from '@pixygon/analytics'\nreportError(err, { userId })\n```\n\n### 7. Verify it worked\n\n```bash\nnode node_modules/@pixygon/analytics/verify.mjs\n# or, from Dyson's registry:\npearl verify analytics\n```\n\nStatic check — installed version, provider/init present, real non-placeholder\n`projectId` matching `.pixygon.json`, endpoint pointing at `/v1/analytics`, and\nwhether consent-free basic mode is wired. Exit 0 pass, 1 fail.\n\nThe basic-mode line is **informational**: it never fails a repo that hasn't\nadopted basic mode, but it *does* warn when it finds a local copy of the module\nstill in place, or a two-state consent gate that leaves undecided visitors\nunmeasured.\n\nAdd a **live** probe of the ingest endpoint:\n\n```bash\nnode node_modules/@pixygon/analytics/verify.mjs --url=https://api.pixygon.com/v1/analytics\n```\n\nThat POSTs one real `pearl_verify` event (it will show up in the project's\ncustom events — that is the point) and asserts `{success:true, processed:1}`.\n\n**What verify can and cannot prove.** It proves *installed + configured*, and\nwith `--url` that the endpoint is alive. It cannot prove events actually leave\na real browser: the consent gate, bot gate, CSP, and ad-blockers all sit\nbetween this config and the server. The only end-to-end proof is opening the\nsite in a real browser, accepting cookies, and watching the visit appear in\nthe admin Analytics tab.\n\n---\n\n## Consent-free basic mode\n\n### The problem it solves\n\nCookie-consent gating cut measured traffic to near zero across the estate. The\nreason is **not** that people decline — it is that most visitors never tap\neither banner button. In-app browsers (Facebook's above all, which is the bulk\nof paid-social traffic) are the worst case. A two-state gate — `accepted` vs\neverything-else — therefore measures the undecided majority as if they had\nnever existed. Lønnlyst showed **371 real sessions as ~0 conversions**.\n\nBasic mode is the third state: the maximum measurement that is lawful with\n**no consent at all**.\n\n```\nskip (bot/dev)   → nothing\nconsent === null → BASIC mode      ← the state that was missing\n'accepted'       → full SDK (basic stops automatically)\n'declined'       → nothing\n```\n\n### The exact legal line\n\nBasic mode needs no consent because it stays on the right side of two separate\nrules, and it must keep doing both:\n\n1. **ePrivacy Directive art. 5(3)** (in Norway: ekomloven § 2-7b) requires\n   consent to *store information on, or gain access to information stored in,*\n   a user's terminal equipment. Basic mode writes and reads **nothing** — no\n   cookies, no `localStorage`, no `sessionStorage`, no IndexedDB, no cache\n   probing. The consent requirement is therefore not triggered at all. This is\n   the same basis Plausible/Fathom-style \"cookieless analytics\" run on.\n2. **GDPR** still applies to whatever *is* processed. Basic mode's defence is\n   that it processes no personal data and creates no identifier that could\n   single anyone out: the session id is a module variable regenerated on every\n   page load, so it cannot follow a visitor across visits or be joined to any\n   other dataset. There is no user id, no fingerprint, no full referrer, no\n   UTM.\n\n**Where the line is drawn — do not cross these:**\n\n| Allowed in basic mode | NOT allowed (needs consent) |\n|---|---|\n| page views (`origin + pathname`) | query strings, hashes, full URLs |\n| explicit funnel counters (`conversion_type`) | conversion `value`, order ids, cart contents |\n| referrer **host** (`l.facebook.com`) | full referrer URL |\n| a per-page-load ephemeral session id | any persisted or derivable id |\n| — | UTM / campaign parameters |\n| — | screen size, user agent, language, device type |\n| — | user ids, emails, logged-in state |\n| — | clicks, scroll depth, time-on-page, errors |\n\nTwo things that are **not** optional even though consent is:\n\n- **GDPR art. 13 transparency still applies.** You must disclose basic mode in\n  the privacy policy. Running it silently is not the deal.\n- **IP addresses reach the server** as they do for any HTTP request. The\n  Pixygon ingest must not store raw IPs against these events. That is a\n  server-side property; this package cannot enforce it.\n\n⚠ **This is engineering guidance written from how the estate operates, not\nlegal advice.** If a specific site has a DPA or a supervisory-authority\ncommitment that says otherwise, that wins.\n\n### Why it is a separate module, not a flag on the SDK\n\n`core.ts` writes `localStorage` from its constructor path — anonymous id,\nsession id, UTM params, and the event queue. A `consent: 'basic'` flag threaded\nthrough that code could regress into writing storage with one careless edit,\nand the regression would be invisible. `src/basic.ts` is instead a standalone\n~120-line module you can audit end to end: **it contains no storage API at\nall**, which is a property you can grep for and which `npm run test:basic`\nasserts at runtime.\n\n### Wiring it (React — the paved path)\n\nReplace your hand-rolled gate with `<AnalyticsGate>`; it is the whole thing:\n\n```tsx\nimport { AnalyticsGate } from '@pixygon/analytics/react'\n\nconst consent = useSyncExternalStore(subscribeConsent, getConsent)\n\n<AnalyticsGate\n  consent={consent}                                  // 'accepted' | 'declined' | null\n  config={analyticsConfig}                           // same config as AnalyticsProvider\n  skip={isLikelyBot() || isDevTraffic()}\n>\n  <App />\n</AnalyticsGate>\n```\n\nThen **nothing else changes**: `usePageTracking()` and `useTrackConversion()`\nalready fall through to basic mode when the consented provider is not mounted,\nso your existing route tracking and conversion calls keep working in the\npre-consent state with no new call sites. Only `data.type` crosses over on a\nconversion — `value` is deliberately dropped.\n\nYour consent store must be **three-state**. The single most common mistake is\n`getConsent()` returning `'declined'` for \"no answer yet\"; that collapses basic\nmode back to nothing:\n\n```ts\nexport const getConsent = (): ConsentChoice => {\n  try { return (localStorage.getItem(KEY) as ConsentChoice) ?? null } catch { return null }\n}                                                   // ← null, NOT 'declined'\n```\n\n### Wiring it (imperative / non-React)\n\n```ts\nimport { init, startBasic, stopBasic, basicPageView, basicConversion } from '@pixygon/analytics'\n\nconst consent = localStorage.getItem('cookie_consent')       // 'accepted' | 'declined' | null\nif (consent === 'accepted') init({ projectId, appName })     // init() calls stopBasic() itself\nelse if (consent !== 'declined') startBasic({ projectId })   // undecided → measure anyway\n\n// on the banner's Accept:  init(...)        — handover is automatic\n// on the banner's Decline: stopBasic()\n```\n\nThe accept-handover is enforced **inside `init()`**, not in your gate: the two\nmodes can never both run, so nobody can double-count by wiring the gate wrong.\n\n### REQUIRED copy\n\nAdopting basic mode obliges you to change two pieces of user-facing text. This\nis not optional polish — it is the transparency half of the legal basis.\n\nThe copy below matches the **recommended** behaviour — the one\n`<AnalyticsGate>` implements: basic mode runs only *before* a choice is made,\nand a decline stops everything. If you deviate from that, the copy must\ndeviate with it.\n\n**Cookie banner** — the banner must disclose that something is counted before\nthe visitor answers. Silence there is the part that is not defensible:\n\n> **Norwegian (estate default):**\n> \"Vi bruker informasjonskapsler til statistikk, slik at vi kan forbedre\n> nettstedet. Inntil du velger, teller vi kun anonyme sidevisninger — uten å\n> lagre noe på enheten din og uten å samle inn personopplysninger.\n> [Godta] [Avslå]\"\n>\n> **English:**\n> \"We use cookies for analytics so we can improve the site. Until you choose,\n> we count anonymous page views only — without storing anything on your device\n> and without collecting personal data. [Accept] [Decline]\"\n\nBoth buttons must be equally prominent — a dark-patterned decline undermines\nthe consent you collect for the *full* SDK.\n\n**Privacy policy** — add a paragraph of this shape:\n\n> **Statistikk uten informasjonskapsler.** Før du har tatt et valg om\n> informasjonskapsler, teller vi kun anonyme sidevisninger og et fåtall\n> hendelser (for eksempel «registrering fullført»). Vi lagrer ingenting på\n> enheten din, bruker ingen informasjonskapsler, og oppretter ingen\n> identifikator som kan følge deg mellom besøk eller mellom nettsteder. Vi\n> registrerer hvilken side som ble besøkt og hvilket nettsted du kom fra (kun\n> domenenavnet) — ikke adressen i sin helhet. Dette er ikke personopplysninger\n> og krever derfor ikke samtykke. Velger du «Avslå», stopper også denne\n> tellingen.\n\n⚠ If you deliberately keep basic mode running after a decline (allowed by\nePrivacy, but **not** what `<AnalyticsGate>` does and not recommended — a\ndecline is a clear signal), then both texts must say so explicitly: change\n\"Inntil du velger\" to \"Uansett hva du velger\" and drop the final sentence of\nthe privacy paragraph. Shipping the recommended copy while running the other\nbehaviour is the one combination that is genuinely indefensible.\n\n### Reading the data\n\nBasic events are tagged `consent_mode: 'basic'` and carry\n`session_id: \"anon_…\"`. Split on that tag when reporting:\n\n- **Basic + full together** = the true traffic shape. Use this for trends.\n- **Full only** = the attributable slice (UTM, returning visitors, user ids).\n- **Do not** compute unique visitors, bounce rate, or session duration from\n  basic events. One page-load is one session by construction, so uniques are\n  inflated and every basic session looks like a bounce. That limitation is the\n  price of not needing consent.\n\n### Proving the invariants\n\n```bash\nnpm run test:basic          # from the package\n```\n\nFakes a browser and asserts, at runtime: nothing touches\n`localStorage`/`sessionStorage`/`document.cookie`, `keepalive` is set, the\nreferrer is host-only, the URL carries no query/hash, no UTM or fingerprint\nfields appear, the session id is the ephemeral `anon_` form, and `init()`\nstops basic mode so the two can never double-count. 19 checks.\n\n### Adoption status (2026-08-07)\n\n`lonnlyst.no` and `Tastebud` currently carry **local copies** of this module\n(`src/utils/basicAnalytics.ts`, ~85 lines each, plus a ~60-line hand-rolled\ngate). Those copies came first and this package version was upstreamed from\nthem.\n\n**This package version is NOT published to npm yet** (no publish token in the\nsession that built it). Until `@pixygon/analytics@1.3.0` is on the registry,\napps keep their local copy. Once it is published, each app should:\n\n1. `npm i @pixygon/analytics@^1.3.0`\n2. delete `src/utils/basicAnalytics.ts` and the hand-rolled gate component\n3. mount `<AnalyticsGate>` per above\n4. `node node_modules/@pixygon/analytics/verify.mjs` — the basic-mode line\n   should read \"wired from the package\"\n\n`verify.mjs` reports adoption as a **recommendation** and never fails a repo\nfor not having it; it does warn when it finds a local copy still in place.\n\n---\n\n## API\n\n| Export | From | Use |\n|---|---|---|\n| `init(config)` | `@pixygon/analytics` | imperative init (throws without `projectId`) |\n| `track` / `page` / `identify` | `@pixygon/analytics` | module-level helpers (no-op before init) |\n| `trackConversion` / `trackAIGeneration` | `@pixygon/analytics` | funnel + AI-cost events |\n| `reportError(err, ctx)` | `@pixygon/analytics` | manual Discord error report |\n| `flush()` / `getSession()` / `destroy()` | `@pixygon/analytics` | batching + session control |\n| `startBasic({projectId, endpoint?})` | both | consent-free mode ON (see above) |\n| `stopBasic()` / `isBasicActive()` | both | consent-free mode OFF / state |\n| `basicPageView(path)` / `basicConversion(type)` | both | the only two basic-mode events |\n| `<AnalyticsGate consent config skip>` | `@pixygon/analytics/react` | **the three-state gate — use this** |\n| `<AnalyticsProvider config>` | `@pixygon/analytics/react` | the raw mount (the gate wraps it) |\n| `<AnalyticsErrorBoundary>` | `@pixygon/analytics/react` | React errors → analytics + Discord |\n| `usePageTracking()` | `@pixygon/analytics/react` | SPA route → `page_view` |\n| `useTrackEvent()` / `useTrackConversion()` / `useIdentify()` | `@pixygon/analytics/react` | the hooks you'll actually use |\n| `useAnalytics()` / `useSession()` / `useFlush()` | `@pixygon/analytics/react` | state + control |\n| `EVENTS`, `EVENT_PROPERTIES` | both | the canonical event/property names |\n\n### Config\n\n```ts\n{\n  projectId: string          // REQUIRED — .pixygon.json ObjectId\n  appName?: string           // shown in Discord error reports\n  endpoint?: string          // default https://api.pixygon.com/v1/analytics\n  autoTrack?: boolean        // default true\n  trackClicks?: boolean      // default true\n  trackTimeSpent?: boolean   // default true\n  trackErrors?: boolean      // default true\n  trackOutboundLinks?: boolean\n  trackPerformance?: boolean\n  batchSize?: number         // default 10\n  batchTimeout?: number      // default 5000 ms\n  debug?: boolean\n  storagePrefix?: string     // default px_analytics_<projectId>\n}\n```\n\n## Gotchas\n\n- **`autoTrack: false` does not silence the SDK.** `session_start` comes from\n  the constructor and `page_view` from `usePageTracking`. To silence it, don't\n  mount the provider.\n- **A slug `projectId` is fragile.** Checked 2026-08-07: every slug in use\n  across the estate (`kikortet-no`, `studiohemstad`, `cvfilm`, `norsats-no`,\n  `villgress-no`, `recurify`, `solarhelp`, `pixygon-mobil`, `PixygonSupport`)\n  currently *does* resolve to the right project. But resolution goes through\n  `Project.slug` / kebab-cased `Project.title`, so **renaming a project in the\n  admin silently orphans that app's analytics** — with no error anywhere. See\n  step 2; use the ObjectId.\n- **`session_end` rarely fires**, so \"bounce rate\" derived from it was fake\n  0% for a long time — don't build reporting on it.\n- **Two providers, one page** = double counting. Mount exactly one.\n- **Vendored copies exist.** A few older repos (e.g. `kikortet.no/src/sdk/analytics`)\n  still carry a hand-rolled SDK next to the package. Delete the vendored copy\n  when you retrofit; two SDKs means two sessions per visitor.\n- **A two-state consent gate flatlines your numbers.** `consent !== 'accepted'`\n  lumps the undecided majority in with the decliners. Use the three-state\n  `<AnalyticsGate>`; make sure your consent store returns `null` (not\n  `'declined'`) for \"hasn't answered\".\n- **Basic mode can't do uniques, bounce, or session duration.** Every basic\n  session is one page-load by construction. Reporting built on those fields\n  will be wrong; split on `consent_mode` first.\n- **The dev's own laptop is traffic too.** Without `isDevTraffic()`, every\n  `npm run dev` writes into the live project (that's the \"276 visits / 0\n  conversions / 4.2h session\" shape).\n\n## Publishing\n\n```bash\nnpm run build && npm publish --access public\n```\n","readmeFilename":"README.md"}