@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 +73 -0
- package/src/core/client.ts +139 -0
- package/src/core/index.ts +22 -0
- package/src/core/transport.ts +124 -0
- package/src/core/types.ts +86 -0
- package/src/events.ts +60 -0
- package/src/index.ts +37 -0
- package/src/next/PageviewTracker.tsx +93 -0
- package/src/react/context.tsx +79 -0
- package/src/transports/djangocfg.ts +64 -0
- package/src/transports/ga4.ts +59 -0
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
|
+
}
|