@djangocfg/analytics 2.1.476

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@djangocfg/analytics",
3
+ "version": "2.1.476",
4
+ "description": "First-party analytics for django-cfg apps: one route listener, N transports (self-hosted + GA4). Pageviews, sessions, and authenticated-user attribution.",
5
+ "keywords": [
6
+ "django",
7
+ "analytics",
8
+ "pageviews",
9
+ "ga4",
10
+ "privacy",
11
+ "react",
12
+ "nextjs",
13
+ "typescript"
14
+ ],
15
+ "author": {
16
+ "name": "DjangoCFG",
17
+ "url": "https://djangocfg.com"
18
+ },
19
+ "homepage": "https://djangocfg.com",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/markolofsen/django-cfg.git",
23
+ "directory": "packages/analytics"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/markolofsen/django-cfg/issues"
27
+ },
28
+ "license": "MIT",
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "exports": {
32
+ ".": {
33
+ "types": "./src/index.ts",
34
+ "import": "./src/index.ts",
35
+ "require": "./src/index.ts"
36
+ },
37
+ "./core": {
38
+ "types": "./src/core/index.ts",
39
+ "import": "./src/core/index.ts",
40
+ "require": "./src/core/index.ts"
41
+ }
42
+ },
43
+ "files": [
44
+ "src",
45
+ "README.md"
46
+ ],
47
+ "scripts": {
48
+ "check": "tsc --noEmit",
49
+ "lint": "eslint ."
50
+ },
51
+ "peerDependencies": {
52
+ "next": ">=15.0.0",
53
+ "react": "^19.2.4",
54
+ "react-dom": "^19.2.4",
55
+ "react-ga4": "^2.1.0"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "react-ga4": {
59
+ "optional": true
60
+ },
61
+ "next": {
62
+ "optional": true
63
+ }
64
+ },
65
+ "devDependencies": {
66
+ "@djangocfg/typescript-config": "^2.1.476",
67
+ "@types/node": "^25.9.5",
68
+ "@types/react": "19.2.15",
69
+ "next": "^15.5.9",
70
+ "react-ga4": "^2.1.0",
71
+ "typescript": "^5.9.3"
72
+ }
73
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The analytics client. Framework-agnostic, zero React.
3
+ *
4
+ * One instance owns the fan-out to every transport, plus the two pieces of
5
+ * state that MUST be module-scoped rather than component-scoped:
6
+ *
7
+ * - the dedup key (see `trackPageview`)
8
+ * - the flush listeners (registered once, in `init`)
9
+ *
10
+ * Nothing is registered at module top level: listeners attach only inside
11
+ * `init()`. A top-level side effect would defeat `sideEffects: false` and get
12
+ * tree-shaken in some bundlers while surviving in others — the worst kind of
13
+ * inconsistency.
14
+ */
15
+
16
+ import type { AnalyticsEventPayload, AnalyticsOptions, AnalyticsTransport } from './types'
17
+
18
+ export class AnalyticsClient {
19
+ private transports: AnalyticsTransport[] = []
20
+ private started = false
21
+ private debug: boolean
22
+
23
+ /**
24
+ * The last pageview key we sent.
25
+ *
26
+ * MUST live here, not in a React ref. React 19 StrictMode remounts components
27
+ * in development, and a `useRef` is reset by that remount — so the guard would
28
+ * not guard. A module/instance-scoped value survives it.
29
+ *
30
+ * This is also what makes `router.refresh()` not count as a pageview: the URL
31
+ * key is unchanged, so it is suppressed.
32
+ */
33
+ private lastPageviewKey: string | null = null
34
+
35
+ private flushHandler?: () => void
36
+ private pagehideHandler?: () => void
37
+
38
+ constructor(options: AnalyticsOptions) {
39
+ this.transports = options.transports ?? []
40
+ this.debug = options.debug ?? true
41
+ }
42
+
43
+ async init(): Promise<void> {
44
+ if (this.started || typeof window === 'undefined') return
45
+ this.started = true
46
+
47
+ for (const t of this.transports) {
48
+ try {
49
+ await t.init?.()
50
+ } catch (err) {
51
+ // A broken transport must never break the page it is measuring.
52
+ this.warn(`transport "${t.name}" failed to init`, err)
53
+ }
54
+ }
55
+
56
+ this.registerFlushListeners()
57
+ }
58
+
59
+ /**
60
+ * Track a pageview, unless it is a duplicate of the last one.
61
+ *
62
+ * The key is the full URL, so a `router.refresh()` or a StrictMode double
63
+ * effect collapses into one event, while a real navigation always fires.
64
+ */
65
+ trackPageview(payload: AnalyticsEventPayload): void {
66
+ const key = `${payload.pathname}?${payload.props?.search ?? ''}`
67
+ if (key === this.lastPageviewKey) return
68
+ this.lastPageviewKey = key
69
+
70
+ this.fanOut((t) => t.pageview(payload))
71
+ }
72
+
73
+ trackEvent(name: string, props: Record<string, unknown> = {}): void {
74
+ if (typeof window === 'undefined') return
75
+
76
+ this.fanOut((t) =>
77
+ t.event({
78
+ event_name: name,
79
+ pathname: window.location.pathname,
80
+ props,
81
+ }),
82
+ )
83
+ }
84
+
85
+ /**
86
+ * Attach (or clear) the authenticated user.
87
+ *
88
+ * Pass `null` on logout. A user id left attached after logout is a real
89
+ * privacy leak — the next anonymous session inherits it.
90
+ */
91
+ identify(userId: string | null): void {
92
+ this.fanOut((t) => t.identify?.(userId))
93
+ }
94
+
95
+ flush(final = false): void {
96
+ this.fanOut((t) => t.flush?.(final))
97
+ }
98
+
99
+ destroy(): void {
100
+ if (this.flushHandler) {
101
+ document.removeEventListener('visibilitychange', this.flushHandler)
102
+ }
103
+ if (this.pagehideHandler) {
104
+ window.removeEventListener('pagehide', this.pagehideHandler)
105
+ }
106
+ this.started = false
107
+ }
108
+
109
+ private registerFlushListeners(): void {
110
+ // visibilitychange -> hidden is the PRIMARY signal, and pagehide is the
111
+ // backstop.
112
+ //
113
+ // Deliberately NOT 'unload': it disqualifies the page from the bfcache
114
+ // entirely. And deliberately not 'beforeunload': also bfcache-hostile, and
115
+ // iOS Safari may never fire it at all — so a beforeunload-only flush loses
116
+ // every mobile Safari session.
117
+ this.flushHandler = () => {
118
+ if (document.visibilityState === 'hidden') this.flush(true)
119
+ }
120
+ this.pagehideHandler = () => this.flush(true)
121
+
122
+ document.addEventListener('visibilitychange', this.flushHandler)
123
+ window.addEventListener('pagehide', this.pagehideHandler)
124
+ }
125
+
126
+ private fanOut(fn: (t: AnalyticsTransport) => void): void {
127
+ for (const t of this.transports) {
128
+ try {
129
+ fn(t)
130
+ } catch (err) {
131
+ this.warn(`transport "${t.name}" threw`, err)
132
+ }
133
+ }
134
+ }
135
+
136
+ private warn(message: string, err?: unknown): void {
137
+ if (this.debug) console.warn(`[analytics] ${message}`, err)
138
+ }
139
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Framework-agnostic entry point. Zero React, zero Next.
3
+ *
4
+ * Import from here in a Vite/vanilla app, or anywhere you want the client
5
+ * without pulling React into the graph.
6
+ */
7
+
8
+ export { AnalyticsClient } from './client'
9
+ export type { AnalyticsEventPayload, AnalyticsOptions, AnalyticsTransport } from './types'
10
+
11
+ /**
12
+ * The batched beacon transport. Also imported by @djangocfg/devtools — the
13
+ * unload-delivery rules (sendBeacon's silent-failure trap, the shared keepalive
14
+ * quota) are implemented ONCE, here, rather than re-derived per consumer.
15
+ */
16
+ export { BeaconTransport } from './transport'
17
+ export type { BeaconOptions } from './transport'
18
+
19
+ export { djangocfg } from '../transports/djangocfg'
20
+ export type { DjangoCfgTransportOptions } from '../transports/djangocfg'
21
+ export { ga4 } from '../transports/ga4'
22
+ export type { Ga4TransportOptions } from '../transports/ga4'
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The batched beacon transport. THE one place this logic exists.
3
+ *
4
+ * Owned by @djangocfg/analytics and imported by @djangocfg/devtools, so the
5
+ * unload-delivery rules below are implemented once rather than re-derived (and
6
+ * re-broken) per consumer. Zero React, zero UI, zero framework — safe to import
7
+ * from anywhere, including a vanilla capture hook.
8
+ *
9
+ * ─────────────────────────────────────────────────────────────────────────────
10
+ * THE TRAP THIS EXISTS TO PREVENT
11
+ * ─────────────────────────────────────────────────────────────────────────────
12
+ * The obvious unload flush loses data SILENTLY:
13
+ *
14
+ * navigator.sendBeacon(url, new Blob([json], { type: 'application/json' }))
15
+ *
16
+ * `application/json` is NOT a CORS-safelisted Content-Type, so the request
17
+ * leaves no-cors mode and triggers an OPTIONS preflight — which, during unload,
18
+ * usually cannot complete. The POST is never sent. `sendBeacon` still returns
19
+ * `true`, because true means "queued", not "delivered". No error, no exception,
20
+ * no retry. Verified in Chrome 149.
21
+ *
22
+ * The events you lose are the ones that mattered most: whatever crashed the page
23
+ * and caused the navigation away.
24
+ *
25
+ * Rules, all of which this module encodes:
26
+ * 1. Prefer SAME-ORIGIN ingest (Next rewrites / Vite proxy). Same-origin means
27
+ * no CORS and therefore no preflight, ever. It also dodges ad blockers.
28
+ * 2. On unload, send `text/plain` — CORS-safelisted, so no preflight. The body
29
+ * is still JSON; only the label differs. The server reads it with an
30
+ * additive parser scoped to the ingest view.
31
+ * 3. CHECK sendBeacon's boolean return and fall back to fetch(keepalive).
32
+ * 4. Respect the 64KB keepalive quota, which is per-document and SHARED
33
+ * between sendBeacon and fetch(keepalive).
34
+ */
35
+
36
+ /** Stay under the shared 64KB per-document keepalive quota. */
37
+ const KEEPALIVE_LIMIT = 60_000
38
+
39
+ export interface BeaconOptions {
40
+ /** Where to POST. Relative keeps it same-origin — see rule 1. */
41
+ url: string
42
+
43
+ /**
44
+ * Called when the queue is drained. Receives the events being sent, and must
45
+ * return the request body. Lets each consumer keep its own payload shape.
46
+ */
47
+ serialize: (events: unknown[]) => string
48
+
49
+ /** Flush automatically once the queue reaches this size. */
50
+ batchSize?: number
51
+
52
+ /** Send cookies, so the server can attribute events to a signed-in user. */
53
+ credentials?: RequestCredentials
54
+ }
55
+
56
+ export class BeaconTransport {
57
+ private queue: unknown[] = []
58
+ private readonly url: string
59
+ private readonly serialize: (events: unknown[]) => string
60
+ private readonly batchSize: number
61
+ private readonly credentials: RequestCredentials
62
+
63
+ constructor(options: BeaconOptions) {
64
+ this.url = options.url
65
+ this.serialize = options.serialize
66
+ this.batchSize = options.batchSize ?? 25
67
+ this.credentials = options.credentials ?? 'include'
68
+ }
69
+
70
+ /** Queue an event, flushing automatically once the batch is full. */
71
+ push(event: unknown): void {
72
+ this.queue.push(event)
73
+ if (this.queue.length >= this.batchSize) this.flush(false)
74
+ }
75
+
76
+ /**
77
+ * Send what is queued.
78
+ *
79
+ * `final` must be true when the document is going away — it switches to a
80
+ * delivery mechanism that survives unload. Getting this wrong is the entire
81
+ * point of this module.
82
+ */
83
+ flush(final: boolean): void {
84
+ if (this.queue.length === 0) return
85
+
86
+ const events = this.queue.splice(0, this.batchSize)
87
+ const body = this.serialize(events)
88
+
89
+ if (final) {
90
+ // A raw string (not a typed Blob) sends as text/plain, which is
91
+ // CORS-safelisted — so no preflight, and it actually leaves the browser.
92
+ if (body.length < KEEPALIVE_LIMIT && navigator.sendBeacon?.(this.url, body)) {
93
+ return
94
+ }
95
+ // sendBeacon refused: over quota, or unavailable. keepalive is the
96
+ // documented fallback that also outlives the document.
97
+ void fetch(this.url, {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'text/plain' },
100
+ body,
101
+ keepalive: true,
102
+ credentials: this.credentials,
103
+ }).catch(() => {
104
+ // The page is dying. There is nothing useful left to do.
105
+ })
106
+ return
107
+ }
108
+
109
+ void fetch(this.url, {
110
+ method: 'POST',
111
+ // Not an unload flush, so the honest content type is fine.
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body,
114
+ credentials: this.credentials,
115
+ }).catch(() => {
116
+ // Telemetry must never surface an error into the app it is measuring.
117
+ })
118
+ }
119
+
120
+ /** Number of events waiting to be sent. */
121
+ get pending(): number {
122
+ return this.queue.length
123
+ }
124
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Core types. No React, no framework, no DOM assumptions beyond `window`.
3
+ */
4
+
5
+ /** A single tracked event. `pageview` is the default and most common. */
6
+ export interface AnalyticsEventPayload {
7
+ event_name: string
8
+
9
+ /** Raw URL path, e.g. `/en/blog/hello-world`. */
10
+ pathname: string
11
+
12
+ /**
13
+ * Templated route, e.g. `/[locale]/blog/[slug]`.
14
+ *
15
+ * Without this, `/en/pricing` and `/ru/pricing` fragment into two separate
16
+ * "pages" and the top-pages report is meaningless on a locale-prefixed site.
17
+ */
18
+ route?: string
19
+
20
+ /** Lifted out of the path into its own dimension, for the same reason. */
21
+ locale?: string
22
+
23
+ hostname?: string
24
+ page_title?: string
25
+ referrer?: string
26
+
27
+ utm_source?: string
28
+ utm_medium?: string
29
+ utm_campaign?: string
30
+ utm_content?: string
31
+ utm_term?: string
32
+
33
+ click_id?: string
34
+ click_id_param?: string
35
+
36
+ /** Arbitrary custom properties. Stored as jsonb server-side. */
37
+ props?: Record<string, unknown>
38
+ }
39
+
40
+ /**
41
+ * A destination for events.
42
+ *
43
+ * The whole point of the package: ONE route listener fans out to N transports.
44
+ * Before this, the GA4 layer owned its own route listener; adding a second
45
+ * destination would have meant a second listener, double-firing with a drifting
46
+ * taxonomy.
47
+ */
48
+ export interface AnalyticsTransport {
49
+ readonly name: string
50
+
51
+ /** Called once, on init. Must not throw. */
52
+ init?(): void | Promise<void>
53
+
54
+ /** Record a pageview. */
55
+ pageview(event: AnalyticsEventPayload): void
56
+
57
+ /** Record a custom event. */
58
+ event(event: AnalyticsEventPayload): void
59
+
60
+ /** Attach an authenticated identity. Pass `null` on logout. */
61
+ identify?(userId: string | null): void
62
+
63
+ /**
64
+ * Send anything buffered. Called on visibilitychange->hidden and pagehide.
65
+ *
66
+ * `final` is true when the document is going away, which is when a transport
67
+ * must use a delivery mechanism that survives unload (sendBeacon /
68
+ * fetch keepalive) rather than a normal fetch.
69
+ */
70
+ flush?(final: boolean): void
71
+ }
72
+
73
+ export interface AnalyticsOptions {
74
+ transports: AnalyticsTransport[]
75
+
76
+ /**
77
+ * Track in development too.
78
+ *
79
+ * Defaults to TRUE, which reverses the old GA4 layer's behavior. That layer
80
+ * was `isProd`-gated, so it was silent in dev — you could not tell whether
81
+ * tracking worked until you shipped. Self-hosting removes the reason for that
82
+ * gate: your own dev traffic goes to your own database, which is arguably the
83
+ * entire point.
84
+ */
85
+ debug?: boolean
86
+ }
package/src/events.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Analytics Events Constants
3
+ *
4
+ * Predefined event names and categories for consistent tracking
5
+ * across the entire application.
6
+ */
7
+
8
+ /**
9
+ * Event Categories
10
+ */
11
+ export const AnalyticsCategory = {
12
+ AUTH: 'auth',
13
+ ERROR: 'error',
14
+ NAVIGATION: 'navigation',
15
+ ENGAGEMENT: 'engagement',
16
+ USER: 'user',
17
+ } as const;
18
+
19
+ /**
20
+ * Predefined Event Names
21
+ */
22
+ export const AnalyticsEvent = {
23
+ // Auth Events
24
+ AUTH_OTP_REQUEST: 'auth_otp_request',
25
+ AUTH_OTP_VERIFY_SUCCESS: 'auth_otp_verify_success',
26
+ AUTH_OTP_VERIFY_FAIL: 'auth_otp_verify_fail',
27
+ AUTH_LOGIN_SUCCESS: 'auth_login_success',
28
+ AUTH_LOGOUT: 'auth_logout',
29
+ AUTH_SESSION_EXPIRED: 'auth_session_expired',
30
+ AUTH_TOKEN_REFRESH: 'auth_token_refresh',
31
+ AUTH_TOKEN_REFRESH_FAIL: 'auth_token_refresh_fail',
32
+
33
+ // OAuth Events
34
+ AUTH_OAUTH_START: 'auth_oauth_start',
35
+ AUTH_OAUTH_SUCCESS: 'auth_oauth_success',
36
+ AUTH_OAUTH_FAIL: 'auth_oauth_fail',
37
+
38
+ // Error Events
39
+ ERROR_BOUNDARY: 'error_boundary',
40
+ ERROR_API: 'error_api',
41
+ ERROR_VALIDATION: 'error_validation',
42
+ ERROR_NETWORK: 'error_network',
43
+
44
+ // Navigation Events
45
+ NAV_ADMIN_ENTER: 'nav_admin_enter',
46
+ NAV_DASHBOARD_ENTER: 'nav_dashboard_enter',
47
+ NAV_PAGE_VIEW: 'nav_page_view',
48
+
49
+ // Engagement Events
50
+ THEME_CHANGE: 'theme_change',
51
+ SIDEBAR_TOGGLE: 'sidebar_toggle',
52
+ MOBILE_MENU_OPEN: 'mobile_menu_open',
53
+
54
+ // User Events
55
+ USER_PROFILE_VIEW: 'user_profile_view',
56
+ USER_PROFILE_UPDATE: 'user_profile_update',
57
+ } as const;
58
+
59
+ export type AnalyticsCategoryType = typeof AnalyticsCategory[keyof typeof AnalyticsCategory];
60
+ export type AnalyticsEventType = typeof AnalyticsEvent[keyof typeof AnalyticsEvent];
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @djangocfg/analytics
3
+ *
4
+ * One route listener, N transports.
5
+ *
6
+ * @example
7
+ * ```tsx
8
+ * <AnalyticsProvider
9
+ * transports={[djangocfg({ site: 'example.com' }), ga4({ trackingId })]}
10
+ * userId={user?.id}
11
+ * >
12
+ * <PageviewTracker route="/[locale]/blog/[slug]" locale={locale} />
13
+ * {children}
14
+ * </AnalyticsProvider>
15
+ * ```
16
+ */
17
+
18
+ export { AnalyticsClient } from './core/client'
19
+ export type {
20
+ AnalyticsEventPayload,
21
+ AnalyticsOptions,
22
+ AnalyticsTransport,
23
+ } from './core/types'
24
+
25
+ export { djangocfg } from './transports/djangocfg'
26
+ export type { DjangoCfgTransportOptions } from './transports/djangocfg'
27
+ export { ga4 } from './transports/ga4'
28
+ export type { Ga4TransportOptions } from './transports/ga4'
29
+
30
+ export { AnalyticsProvider, useAnalytics, useAnalyticsClient } from './react/context'
31
+ export type { AnalyticsProviderProps } from './react/context'
32
+
33
+ export { PageviewTracker } from './next/PageviewTracker'
34
+ export type { PageviewTrackerProps } from './next/PageviewTracker'
35
+
36
+ export { AnalyticsCategory, AnalyticsEvent } from './events'
37
+ export type { AnalyticsCategoryType, AnalyticsEventType } from './events'
@@ -0,0 +1,93 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Next.js App Router pageview tracking.
5
+ *
6
+ * ─────────────────────────────────────────────────────────────────────────────
7
+ * WHY THE <Suspense> BOUNDARY IS MANDATORY
8
+ * ─────────────────────────────────────────────────────────────────────────────
9
+ * `useSearchParams()` without a Suspense boundary above it de-opts the ENTIRE
10
+ * page into client-side rendering. Not this component — the whole page. It is
11
+ * silent, and it is a repo-wide performance regression that shows up as a
12
+ * mysteriously worse Lighthouse score weeks later.
13
+ *
14
+ * `usePathname()` is exempt; only `useSearchParams()` triggers it.
15
+ * `@vercel/analytics` wraps its own tracker in Suspense for exactly this reason.
16
+ *
17
+ * So the inner component is never exported. The exported one is already wrapped.
18
+ */
19
+
20
+ import { usePathname, useSearchParams } from 'next/navigation'
21
+ import { Suspense, useEffect } from 'react'
22
+
23
+ import { useAnalyticsClient } from '../react/context'
24
+
25
+ export interface PageviewTrackerProps {
26
+ /**
27
+ * The templated route, e.g. `/[locale]/blog/[slug]`.
28
+ *
29
+ * Next does not expose this to client components, so the app must pass it.
30
+ * Without it, `/en/pricing` and `/ru/pricing` are counted as two different
31
+ * pages, which makes the top-pages report useless on a locale-prefixed site.
32
+ */
33
+ route?: string
34
+
35
+ /** Current locale, lifted into its own dimension rather than fused into the path. */
36
+ locale?: string
37
+ }
38
+
39
+ function Tracker({ route, locale }: PageviewTrackerProps) {
40
+ const pathname = usePathname()
41
+ const searchParams = useSearchParams()
42
+ const client = useAnalyticsClient()
43
+
44
+ useEffect(() => {
45
+ if (!client || !pathname) return
46
+
47
+ const search = searchParams?.toString() ?? ''
48
+ const params = new URLSearchParams(search)
49
+
50
+ client.trackPageview({
51
+ event_name: 'pageview',
52
+ pathname,
53
+ route,
54
+ locale,
55
+ hostname: window.location.hostname,
56
+ page_title: document.title,
57
+ // Only the FIRST pageview of a visit has a meaningful referrer; on
58
+ // subsequent client-side navigations document.referrer still holds the
59
+ // original external referrer, which is what we want for attribution.
60
+ referrer: document.referrer,
61
+ utm_source: params.get('utm_source') ?? undefined,
62
+ utm_medium: params.get('utm_medium') ?? undefined,
63
+ utm_campaign: params.get('utm_campaign') ?? undefined,
64
+ utm_content: params.get('utm_content') ?? undefined,
65
+ utm_term: params.get('utm_term') ?? undefined,
66
+ ...extractClickId(params),
67
+ // `search` feeds the client's dedup key, so ?page=2 counts as a new
68
+ // pageview while router.refresh() does not.
69
+ props: { search: search ? `?${search}` : '' },
70
+ })
71
+ }, [client, pathname, searchParams, route, locale])
72
+
73
+ return null
74
+ }
75
+
76
+ /** Known ad-platform click ids. One column pair server-side, not six. */
77
+ const CLICK_ID_PARAMS = ['gclid', 'fbclid', 'msclkid', 'ttclid', 'li_fat_id', 'twclid']
78
+
79
+ function extractClickId(params: URLSearchParams) {
80
+ for (const key of CLICK_ID_PARAMS) {
81
+ const value = params.get(key)
82
+ if (value) return { click_id: value, click_id_param: key }
83
+ }
84
+ return {}
85
+ }
86
+
87
+ export function PageviewTracker(props: PageviewTrackerProps) {
88
+ return (
89
+ <Suspense fallback={null}>
90
+ <Tracker {...props} />
91
+ </Suspense>
92
+ )
93
+ }
@@ -0,0 +1,79 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * React bindings. Router-agnostic: the provider owns the client, and a router
5
+ * adapter (see ../next) feeds it location changes.
6
+ */
7
+
8
+ import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from 'react'
9
+
10
+ import { AnalyticsClient } from '../core/client'
11
+ import type { AnalyticsOptions } from '../core/types'
12
+
13
+ const AnalyticsContext = createContext<AnalyticsClient | null>(null)
14
+
15
+ export interface AnalyticsProviderProps extends AnalyticsOptions {
16
+ children: ReactNode
17
+
18
+ /**
19
+ * The signed-in user's id, or null/undefined when anonymous.
20
+ *
21
+ * MUST be an opaque id — never an email. And it MUST go back to null on
22
+ * logout: a stale user_id attached to the next anonymous session is a real
23
+ * privacy leak, not a cosmetic bug.
24
+ */
25
+ userId?: string | null
26
+ }
27
+
28
+ export function AnalyticsProvider({
29
+ children,
30
+ transports,
31
+ debug,
32
+ userId,
33
+ }: AnalyticsProviderProps) {
34
+ // The client must survive React 19 StrictMode's dev remount, or its pageview
35
+ // dedup key would reset and every pageview would fire twice.
36
+ const clientRef = useRef<AnalyticsClient | null>(null)
37
+ if (clientRef.current === null) {
38
+ clientRef.current = new AnalyticsClient({ transports, debug })
39
+ }
40
+ const client = clientRef.current
41
+
42
+ useEffect(() => {
43
+ void client.init()
44
+ return () => client.destroy()
45
+ }, [client])
46
+
47
+ useEffect(() => {
48
+ // Inside an effect, never during render: reading auth state during render
49
+ // produces a server/client hydration mismatch.
50
+ client.identify(userId ?? null)
51
+ }, [client, userId])
52
+
53
+ return <AnalyticsContext.Provider value={client}>{children}</AnalyticsContext.Provider>
54
+ }
55
+
56
+ /**
57
+ * Track custom events from a component.
58
+ *
59
+ * Returns a stable object, so it is safe in a dependency array.
60
+ */
61
+ export function useAnalytics() {
62
+ const client = useContext(AnalyticsContext)
63
+
64
+ return useMemo(
65
+ () => ({
66
+ event: (name: string, props?: Record<string, unknown>) => client?.trackEvent(name, props),
67
+ identify: (userId: string | null) => client?.identify(userId),
68
+ flush: () => client?.flush(false),
69
+ /** False when no AnalyticsProvider is mounted above this component. */
70
+ isEnabled: client !== null,
71
+ }),
72
+ [client],
73
+ )
74
+ }
75
+
76
+ /** Internal: the router adapters need the raw client. */
77
+ export function useAnalyticsClient(): AnalyticsClient | null {
78
+ return useContext(AnalyticsContext)
79
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * First-party transport: POST to django-cfg's /cfg/analytics/<path>/.
3
+ *
4
+ * The delivery rules (unload flush, sendBeacon's silent-failure trap, the shared
5
+ * keepalive quota) live in ../core/transport.ts — ONE implementation, shared with
6
+ * @djangocfg/devtools. Do not re-implement them here.
7
+ */
8
+
9
+ import { BeaconTransport } from '../core/transport'
10
+ import type { AnalyticsEventPayload, AnalyticsTransport } from '../core/types'
11
+
12
+ export interface DjangoCfgTransportOptions {
13
+ /**
14
+ * Where to POST. Defaults to `${NEXT_PUBLIC_API_URL}/cfg/analytics/collect/`,
15
+ * so it stays relative same-origin when the API shares the frontend's origin
16
+ * (empty base — no CORS, no preflight, dodges ad blockers) and becomes
17
+ * absolute on a split-domain deploy where the API lives elsewhere.
18
+ *
19
+ * Configurable because filter lists (EasyPrivacy et al.) match on PATH, not
20
+ * domain: a fixed path shipped in an open-source package gets blocked once,
21
+ * for everyone.
22
+ */
23
+ ingestPath?: string
24
+
25
+ /** Which registered AnalyticsSite these events belong to. */
26
+ site?: string
27
+
28
+ /** Max events per request. The server caps at 25. */
29
+ batchSize?: number
30
+ }
31
+
32
+ export function djangocfg(options: DjangoCfgTransportOptions = {}): AnalyticsTransport {
33
+ const apiBase = (process.env.NEXT_PUBLIC_API_URL ?? '').replace(/\/+$/, '')
34
+ const ingestPath = options.ingestPath ?? `${apiBase}/cfg/analytics/collect/`
35
+ const site = options.site ?? (typeof window !== 'undefined' ? window.location.hostname : '')
36
+
37
+ let userId: string | null = null
38
+
39
+ const beacon = new BeaconTransport({
40
+ url: ingestPath,
41
+ batchSize: Math.min(options.batchSize ?? 25, 25),
42
+ // credentials:'include' is what lets the server resolve request.user and
43
+ // attribute the event to a signed-in account — the differentiator.
44
+ credentials: 'include',
45
+ serialize: (events) => JSON.stringify({ site, events }),
46
+ })
47
+
48
+ const enqueue = (event: AnalyticsEventPayload) => {
49
+ beacon.push({
50
+ ...event,
51
+ props: { ...event.props, ...(userId ? { user_id: userId } : {}) },
52
+ })
53
+ }
54
+
55
+ return {
56
+ name: 'djangocfg',
57
+ pageview: (event) => enqueue({ ...event, event_name: 'pageview' }),
58
+ event: (event) => enqueue(event),
59
+ identify: (id) => {
60
+ userId = id
61
+ },
62
+ flush: (final) => beacon.flush(final),
63
+ }
64
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Google Analytics 4 transport.
3
+ *
4
+ * This is the old `packages/layouts/src/snippets/Analytics` layer, turned into a
5
+ * transport. It kept its own route listener; now it is one destination among N,
6
+ * fed by the single listener in core/client.ts. Two listeners would double-fire
7
+ * with drifting taxonomies.
8
+ *
9
+ * `react-ga4` is loaded LAZILY, so a project that does not pass a GA4 tracking
10
+ * id never pays for the library at all.
11
+ *
12
+ * Behavior change worth knowing about: the old layer hard-gated on `isProd`, so
13
+ * it was completely silent in development. That gate is not reproduced here —
14
+ * whether to track in dev is now the caller's decision (`debug` on the client),
15
+ * not a hardcoded rule. GA4 still filters its own internal traffic server-side.
16
+ */
17
+
18
+ import type { AnalyticsTransport } from '../core/types'
19
+
20
+ export interface Ga4TransportOptions {
21
+ /** Measurement ID, e.g. `G-XXXXXXXXXX`. */
22
+ trackingId: string
23
+ }
24
+
25
+ export function ga4(options: Ga4TransportOptions): AnalyticsTransport {
26
+ const { trackingId } = options
27
+
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ let ReactGA: any = null
30
+
31
+ return {
32
+ name: 'ga4',
33
+
34
+ async init() {
35
+ if (!trackingId) return
36
+ const mod = await import('react-ga4')
37
+ ReactGA = mod.default ?? mod
38
+ ReactGA.initialize(trackingId)
39
+ },
40
+
41
+ pageview(event) {
42
+ if (!ReactGA) return
43
+ const search = (event.props?.search as string) || ''
44
+ ReactGA.send({ hitType: 'pageview', page: `${event.pathname}${search}` })
45
+ },
46
+
47
+ event(event) {
48
+ if (!ReactGA) return
49
+ ReactGA.event(event.event_name, event.props ?? {})
50
+ },
51
+
52
+ identify(userId) {
53
+ if (!ReactGA) return
54
+ // Clearing must send an explicit null, not an omission — GA4 keeps the
55
+ // previous user_id otherwise, and it would bleed into the next session.
56
+ ReactGA.set({ user_id: userId })
57
+ },
58
+ }
59
+ }