@refraction-ui/astro 0.7.0 → 0.8.1
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/dist/analytics-sink-app-insights/app-insights-sink.ts +344 -0
- package/dist/analytics-sink-app-insights/index.ts +14 -0
- package/dist/analytics-sink-app-insights/mapping.ts +171 -0
- package/dist/analytics-sink-ga4/client-sdk-sink.ts +191 -0
- package/dist/analytics-sink-ga4/ga4-sink.ts +27 -0
- package/dist/analytics-sink-ga4/http-sink.ts +141 -0
- package/dist/analytics-sink-ga4/index.ts +20 -0
- package/dist/analytics-sink-ga4/mapping.ts +172 -0
- package/dist/analytics-sink-ga4/types.ts +94 -0
- package/dist/analytics-sink-posthog/client-sdk-sink.ts +160 -0
- package/dist/analytics-sink-posthog/http-sink.ts +214 -0
- package/dist/analytics-sink-posthog/index.ts +26 -0
- package/dist/analytics-sink-posthog/mapping.ts +155 -0
- package/dist/analytics-sink-posthog/replay.ts +152 -0
- package/dist/analytics-sink-posthog/sink.ts +37 -0
- package/dist/astro-analytics/index.ts +45 -0
- package/dist/shared/library-origin-error.ts +1 -2
- package/dist/shared/types.ts +1 -1
- package/dist/theme/theme-script.ts +1 -1
- package/package.json +6 -2
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GA4 `client-sdk` adapter — lazy gtag.js.
|
|
3
|
+
*
|
|
4
|
+
* The vendor library is NEVER a hard dependency and is NEVER bundled. On the
|
|
5
|
+
* first delivery the adapter:
|
|
6
|
+
* 1. installs the gtag stub + dataLayer,
|
|
7
|
+
* 2. pushes the Consent Mode default (if a bridge is configured),
|
|
8
|
+
* 3. lazily injects `https://www.googletagmanager.com/gtag/js?id=<id>`
|
|
9
|
+
* via a `<script>` tag (or an injected loader for tests/SSR),
|
|
10
|
+
* 4. runs `gtag('js', Date)` + `gtag('config', <id>, { send_page_view:false })`.
|
|
11
|
+
*
|
|
12
|
+
* Subsequent deliveries map each canonical envelope through the shared mapper
|
|
13
|
+
* and call `gtag('set', 'user_properties', …)` / `gtag('set', { user_id })` /
|
|
14
|
+
* `gtag('event', name, params)`.
|
|
15
|
+
*
|
|
16
|
+
* Consent Mode bridge: `init` pushes `consent: default`; `deliver` is only
|
|
17
|
+
* reached when the router's consent gate already allows this sink, at which
|
|
18
|
+
* point `consent: update` is pushed for the mapped GA4 consent types.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
AnalyticsEvent,
|
|
23
|
+
AnalyticsSink,
|
|
24
|
+
SinkInitContext,
|
|
25
|
+
} from '../analytics/index.ts'
|
|
26
|
+
import type { GA4ClientSdkOptions, GtagFn } from './types.js'
|
|
27
|
+
import { mapEvent } from './mapping.js'
|
|
28
|
+
|
|
29
|
+
const DEFAULT_SRC_BASE = 'https://www.googletagmanager.com/gtag/js'
|
|
30
|
+
|
|
31
|
+
interface GtagGlobal {
|
|
32
|
+
dataLayer?: unknown[]
|
|
33
|
+
gtag?: GtagFn
|
|
34
|
+
document?: {
|
|
35
|
+
createElement(tag: string): {
|
|
36
|
+
async: boolean
|
|
37
|
+
src: string
|
|
38
|
+
onload: (() => void) | null
|
|
39
|
+
onerror: (() => void) | null
|
|
40
|
+
}
|
|
41
|
+
head?: { appendChild(node: unknown): void }
|
|
42
|
+
getElementsByTagName(tag: string): ArrayLike<{
|
|
43
|
+
parentNode?: { insertBefore(a: unknown, b: unknown): void }
|
|
44
|
+
}>
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** DOM `<script>` injector (browser only). */
|
|
49
|
+
function defaultScriptLoader(src: string): Promise<void> {
|
|
50
|
+
const g = globalThis as unknown as GtagGlobal
|
|
51
|
+
const doc = g.document
|
|
52
|
+
if (!doc || typeof doc.createElement !== 'function') {
|
|
53
|
+
return Promise.reject(
|
|
54
|
+
new Error('GA4 client-sdk sink: no DOM to inject gtag.js'),
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
return new Promise<void>((resolve, reject) => {
|
|
58
|
+
const el = doc.createElement('script')
|
|
59
|
+
el.async = true
|
|
60
|
+
el.src = src
|
|
61
|
+
el.onload = () => resolve()
|
|
62
|
+
el.onerror = () => reject(new Error('GA4 client-sdk: gtag.js failed'))
|
|
63
|
+
if (doc.head?.appendChild) {
|
|
64
|
+
doc.head.appendChild(el)
|
|
65
|
+
} else {
|
|
66
|
+
const first = doc.getElementsByTagName('script')[0]
|
|
67
|
+
first?.parentNode?.insertBefore(el, first)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create the GA4 gtag.js (`client-sdk`) sink. The vendor script is dynamically
|
|
74
|
+
* loaded on first delivery only — there is no static import of any GA4/gtag
|
|
75
|
+
* library, so `http`-mode consumers never pull this code path.
|
|
76
|
+
*/
|
|
77
|
+
export function createGA4ClientSdkSink(
|
|
78
|
+
options: GA4ClientSdkOptions,
|
|
79
|
+
): AnalyticsSink {
|
|
80
|
+
const {
|
|
81
|
+
measurementId,
|
|
82
|
+
consentCategories = ['analytics'],
|
|
83
|
+
name = 'ga4',
|
|
84
|
+
consentMode,
|
|
85
|
+
} = options
|
|
86
|
+
|
|
87
|
+
const g = globalThis as unknown as GtagGlobal
|
|
88
|
+
|
|
89
|
+
let gtag: GtagFn | undefined = options.gtag
|
|
90
|
+
let loaded = false
|
|
91
|
+
let loadPromise: Promise<void> | undefined
|
|
92
|
+
|
|
93
|
+
function ensureGtagStub(): GtagFn {
|
|
94
|
+
if (gtag) return gtag
|
|
95
|
+
// Standard gtag bootstrap: dataLayer-backed shim available synchronously.
|
|
96
|
+
g.dataLayer = g.dataLayer ?? []
|
|
97
|
+
const dl = g.dataLayer
|
|
98
|
+
const fn: GtagFn = (...args: unknown[]) => {
|
|
99
|
+
dl.push(args)
|
|
100
|
+
}
|
|
101
|
+
g.gtag = g.gtag ?? fn
|
|
102
|
+
gtag = g.gtag
|
|
103
|
+
return gtag
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pushConsentDefault(): void {
|
|
107
|
+
if (!consentMode?.default || !gtag) return
|
|
108
|
+
gtag('consent', 'default', { ...consentMode.default })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function pushConsentUpdate(): void {
|
|
112
|
+
if (!consentMode?.map || !gtag) return
|
|
113
|
+
const update: Record<string, 'granted'> = {}
|
|
114
|
+
// deliver() only runs when the router's consent gate already permits this
|
|
115
|
+
// sink (all consentCategories granted) → reflect that to GA4.
|
|
116
|
+
for (const cat of consentCategories) {
|
|
117
|
+
for (const ga4Type of consentMode.map[cat] ?? []) {
|
|
118
|
+
update[ga4Type] = 'granted'
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (Object.keys(update).length > 0) {
|
|
122
|
+
gtag('consent', 'update', update)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ensureLoaded(): Promise<void> {
|
|
127
|
+
if (loaded) return Promise.resolve()
|
|
128
|
+
if (loadPromise) return loadPromise
|
|
129
|
+
|
|
130
|
+
ensureGtagStub()
|
|
131
|
+
pushConsentDefault()
|
|
132
|
+
|
|
133
|
+
// App/test supplied its own gtag → never inject a vendor script.
|
|
134
|
+
if (options.gtag) {
|
|
135
|
+
gtag!('js', new Date())
|
|
136
|
+
gtag!('config', measurementId, { send_page_view: false })
|
|
137
|
+
loaded = true
|
|
138
|
+
return Promise.resolve()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const base = options.gtagSrcBase ?? DEFAULT_SRC_BASE
|
|
142
|
+
const src = `${base}?id=${encodeURIComponent(measurementId)}`
|
|
143
|
+
const load = options.scriptLoader ?? defaultScriptLoader
|
|
144
|
+
|
|
145
|
+
loadPromise = load(src).then(() => {
|
|
146
|
+
gtag!('js', new Date())
|
|
147
|
+
gtag!('config', measurementId, { send_page_view: false })
|
|
148
|
+
loaded = true
|
|
149
|
+
})
|
|
150
|
+
return loadPromise
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
name,
|
|
155
|
+
consentCategories,
|
|
156
|
+
|
|
157
|
+
init(_ctx: SinkInitContext): void {
|
|
158
|
+
// Install the stub + Consent Mode default eagerly so a `consent:
|
|
159
|
+
// default` is in dataLayer before the tag loads. The vendor script
|
|
160
|
+
// itself is still deferred to the first delivery.
|
|
161
|
+
ensureGtagStub()
|
|
162
|
+
pushConsentDefault()
|
|
163
|
+
void _ctx
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async deliver(batch: AnalyticsEvent[]): Promise<void> {
|
|
167
|
+
if (batch.length === 0) return
|
|
168
|
+
await ensureLoaded()
|
|
169
|
+
const send = gtag!
|
|
170
|
+
pushConsentUpdate()
|
|
171
|
+
|
|
172
|
+
for (const ev of batch) {
|
|
173
|
+
const m = mapEvent(ev)
|
|
174
|
+
|
|
175
|
+
if (m.userId) {
|
|
176
|
+
send('set', { user_id: m.userId })
|
|
177
|
+
}
|
|
178
|
+
if (m.userProperties) {
|
|
179
|
+
const flat: Record<string, unknown> = {}
|
|
180
|
+
for (const [k, v] of Object.entries(m.userProperties)) {
|
|
181
|
+
flat[k] = v.value
|
|
182
|
+
}
|
|
183
|
+
send('set', 'user_properties', flat)
|
|
184
|
+
}
|
|
185
|
+
if (m.event) {
|
|
186
|
+
send('event', m.event.name, m.event.params)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createGA4Sink` — the single entry point. Dispatches on `mode`:
|
|
3
|
+
* - `mode: 'client-sdk'` → lazy gtag.js adapter
|
|
4
|
+
* - `mode: 'http'` (default) → Measurement Protocol adapter, no vendor lib
|
|
5
|
+
*
|
|
6
|
+
* Default is `http` to honour the epic's "default = protocol adapters (no
|
|
7
|
+
* vendor libs in the browser)" stance. GA4 is just a sink — register it via
|
|
8
|
+
* `createAnalytics({ sinks: [createGA4Sink(...)] })` or `analytics.addSink`.
|
|
9
|
+
*
|
|
10
|
+
* Both factories are independent modules. The `http` factory has ZERO
|
|
11
|
+
* references to gtag.js / DOM-script code, and the `client-sdk` factory only
|
|
12
|
+
* touches the vendor script lazily (first delivery). Selecting one mode never
|
|
13
|
+
* executes the other's load path; consumers that only ever construct an
|
|
14
|
+
* `http` sink never run any vendor code.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { AnalyticsSink } from '../analytics/index.ts'
|
|
18
|
+
import type { GA4SinkOptions } from './types.js'
|
|
19
|
+
import { createGA4HttpSink } from './http-sink.js'
|
|
20
|
+
import { createGA4ClientSdkSink } from './client-sdk-sink.js'
|
|
21
|
+
|
|
22
|
+
export function createGA4Sink(options: GA4SinkOptions): AnalyticsSink {
|
|
23
|
+
if (options.mode === 'client-sdk') {
|
|
24
|
+
return createGA4ClientSdkSink(options)
|
|
25
|
+
}
|
|
26
|
+
return createGA4HttpSink(options)
|
|
27
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GA4 `http` adapter — Measurement Protocol (`/mp/collect`).
|
|
3
|
+
*
|
|
4
|
+
* Server-relay friendly: this path NEVER loads gtag.js or any browser
|
|
5
|
+
* library. It only POSTs JSON to the Measurement Protocol endpoint:
|
|
6
|
+
*
|
|
7
|
+
* POST {endpoint}/mp/collect?measurement_id={id}&api_secret={secret}
|
|
8
|
+
* Content-Type: application/json
|
|
9
|
+
* { client_id, user_id?, user_properties?, events: [{ name, params }] }
|
|
10
|
+
*
|
|
11
|
+
* GA4 Measurement Protocol constraints honoured here:
|
|
12
|
+
* - up to 25 events per request → batches are chunked.
|
|
13
|
+
* - `identify` envelopes carry no event; they still POST so user_id /
|
|
14
|
+
* user_properties propagate (events array may be empty for a user-props
|
|
15
|
+
* only ping, which GA4 accepts).
|
|
16
|
+
* - 2xx (incl. 204) = accepted. The MP endpoint always returns 2xx for
|
|
17
|
+
* well-formed requests; the `debug` endpoint returns validation messages.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
AnalyticsEvent,
|
|
22
|
+
AnalyticsSink,
|
|
23
|
+
SinkInitContext,
|
|
24
|
+
} from '../analytics/index.ts'
|
|
25
|
+
import type { GA4HttpOptions } from './types.js'
|
|
26
|
+
import { mapEvent } from './mapping.js'
|
|
27
|
+
|
|
28
|
+
const DEFAULT_ENDPOINT = 'https://www.google-analytics.com'
|
|
29
|
+
const MAX_EVENTS_PER_REQUEST = 25
|
|
30
|
+
|
|
31
|
+
interface MPPayload {
|
|
32
|
+
client_id: string
|
|
33
|
+
user_id?: string
|
|
34
|
+
user_properties?: Record<string, { value: unknown }>
|
|
35
|
+
events: Array<{ name: string; params: Record<string, unknown> }>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveFetch(opts: GA4HttpOptions): typeof fetch {
|
|
39
|
+
if (opts.fetchImpl) return opts.fetchImpl
|
|
40
|
+
const f = (globalThis as unknown as { fetch?: typeof fetch }).fetch
|
|
41
|
+
if (!f) throw new Error('GA4 http sink: no fetch implementation available')
|
|
42
|
+
return f
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Group consecutive envelopes by GA4 identity (client_id + user_id) so each
|
|
47
|
+
* Measurement Protocol request carries a single identity, then chunk to the
|
|
48
|
+
* 25-event limit.
|
|
49
|
+
*/
|
|
50
|
+
function buildPayloads(batch: AnalyticsEvent[]): MPPayload[] {
|
|
51
|
+
const payloads: MPPayload[] = []
|
|
52
|
+
|
|
53
|
+
for (const ev of batch) {
|
|
54
|
+
const m = mapEvent(ev)
|
|
55
|
+
const last = payloads[payloads.length - 1]
|
|
56
|
+
const sameIdentity =
|
|
57
|
+
last &&
|
|
58
|
+
last.client_id === m.clientId &&
|
|
59
|
+
last.user_id === m.userId &&
|
|
60
|
+
last.events.length < MAX_EVENTS_PER_REQUEST &&
|
|
61
|
+
// user_properties must not silently differ within one request
|
|
62
|
+
JSON.stringify(last.user_properties) ===
|
|
63
|
+
JSON.stringify(m.userProperties)
|
|
64
|
+
|
|
65
|
+
const target: MPPayload = sameIdentity
|
|
66
|
+
? last
|
|
67
|
+
: (() => {
|
|
68
|
+
const p: MPPayload = { client_id: m.clientId, events: [] }
|
|
69
|
+
if (m.userId) p.user_id = m.userId
|
|
70
|
+
if (m.userProperties) p.user_properties = m.userProperties
|
|
71
|
+
payloads.push(p)
|
|
72
|
+
return p
|
|
73
|
+
})()
|
|
74
|
+
|
|
75
|
+
if (m.event) {
|
|
76
|
+
target.events.push(m.event)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Drop empty payloads that carry neither an event nor user identity
|
|
81
|
+
// signal (nothing to send to GA4).
|
|
82
|
+
return payloads.filter(
|
|
83
|
+
(p) =>
|
|
84
|
+
p.events.length > 0 ||
|
|
85
|
+
p.user_id !== undefined ||
|
|
86
|
+
p.user_properties !== undefined,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create the GA4 Measurement-Protocol (`http`) sink.
|
|
92
|
+
*
|
|
93
|
+
* No vendor library is loaded on this path under any circumstance.
|
|
94
|
+
*/
|
|
95
|
+
export function createGA4HttpSink(options: GA4HttpOptions): AnalyticsSink {
|
|
96
|
+
const {
|
|
97
|
+
measurementId,
|
|
98
|
+
apiSecret,
|
|
99
|
+
consentCategories = ['analytics'],
|
|
100
|
+
name = 'ga4',
|
|
101
|
+
debug = false,
|
|
102
|
+
} = options
|
|
103
|
+
const base = (options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, '')
|
|
104
|
+
const path = debug ? '/debug/mp/collect' : '/mp/collect'
|
|
105
|
+
const url =
|
|
106
|
+
`${base}${path}?measurement_id=${encodeURIComponent(measurementId)}` +
|
|
107
|
+
`&api_secret=${encodeURIComponent(apiSecret)}`
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
name,
|
|
111
|
+
consentCategories,
|
|
112
|
+
|
|
113
|
+
init(_ctx: SinkInitContext): void {
|
|
114
|
+
// No-op: the Measurement Protocol is stateless and credential-driven.
|
|
115
|
+
// Explicitly NO vendor script / SDK is loaded here.
|
|
116
|
+
void _ctx
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async deliver(batch: AnalyticsEvent[]): Promise<void> {
|
|
120
|
+
if (batch.length === 0) return
|
|
121
|
+
const payloads = buildPayloads(batch)
|
|
122
|
+
if (payloads.length === 0) return
|
|
123
|
+
const doFetch = resolveFetch(options)
|
|
124
|
+
|
|
125
|
+
for (const payload of payloads) {
|
|
126
|
+
try {
|
|
127
|
+
await doFetch(url, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
body: JSON.stringify(payload),
|
|
131
|
+
keepalive: true,
|
|
132
|
+
})
|
|
133
|
+
// GA4 MP returns 2xx (204) for well-formed requests and never
|
|
134
|
+
// asks for client-side retries; transient failures are dropped.
|
|
135
|
+
} catch {
|
|
136
|
+
// Network failure — GA4 MP is fire-and-forget; drop silently.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Unified factory (dispatches on `mode`).
|
|
2
|
+
export { createGA4Sink } from './ga4-sink.js'
|
|
3
|
+
|
|
4
|
+
// Direct mode factories (for explicit selection / tree-shaking).
|
|
5
|
+
export { createGA4HttpSink } from './http-sink.js'
|
|
6
|
+
export { createGA4ClientSdkSink } from './client-sdk-sink.js'
|
|
7
|
+
|
|
8
|
+
// Pure mapping (canonical envelope → GA4) — reusable / testable in isolation.
|
|
9
|
+
export { mapEvent, ga4EventName, toGa4Name } from './mapping.js'
|
|
10
|
+
export type { GA4Event, GA4Mapped } from './mapping.js'
|
|
11
|
+
|
|
12
|
+
// Option contracts.
|
|
13
|
+
export type {
|
|
14
|
+
GA4SinkOptions,
|
|
15
|
+
GA4HttpOptions,
|
|
16
|
+
GA4ClientSdkOptions,
|
|
17
|
+
GA4ConsentBridge,
|
|
18
|
+
ConsentState,
|
|
19
|
+
GtagFn,
|
|
20
|
+
} from './types.js'
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical envelope → GA4 mapping.
|
|
3
|
+
*
|
|
4
|
+
* One mapper, two consumers: the `client-sdk` adapter feeds the result into
|
|
5
|
+
* `gtag('event', name, params)` / `gtag('set', ...)`, and the `http` adapter
|
|
6
|
+
* serialises it into a Measurement Protocol `/mp/collect` payload. Keeping the
|
|
7
|
+
* mapping in one place guarantees the two modes stay behaviourally identical.
|
|
8
|
+
*
|
|
9
|
+
* Identity / param mapping (issue #216, epic #213):
|
|
10
|
+
* anonymousId → client_id (GA4 device/browser id)
|
|
11
|
+
* userId → user_id (GA4 User-ID, cross-device)
|
|
12
|
+
* properties → event params (track/page/screen/group payload)
|
|
13
|
+
* identify → user_properties (traits become GA4 user properties)
|
|
14
|
+
* sessionId → session_id param (so GA4 sessionisation can align)
|
|
15
|
+
*
|
|
16
|
+
* GA4 event-name normalisation: GA4 recommends snake_case event names and
|
|
17
|
+
* forbids spaces. `page` → `page_view`, `screen` → `screen_view` (GA4
|
|
18
|
+
* Enhanced-Measurement parity); everything else is lower_snake_cased.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { AnalyticsEvent, AnalyticsProperties } from '../analytics/index.ts'
|
|
22
|
+
|
|
23
|
+
/** A GA4 event ready for gtag.js or the Measurement Protocol. */
|
|
24
|
+
export interface GA4Event {
|
|
25
|
+
/** GA4 event name (snake_case, no spaces). */
|
|
26
|
+
name: string
|
|
27
|
+
/** Event params (GA4 caps these; we pass them through verbatim). */
|
|
28
|
+
params: Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The fully-mapped result for a single canonical envelope. */
|
|
32
|
+
export interface GA4Mapped {
|
|
33
|
+
/** GA4 client_id (from anonymousId). Always present. */
|
|
34
|
+
clientId: string
|
|
35
|
+
/** GA4 user_id (from userId). Present only after identify. */
|
|
36
|
+
userId?: string
|
|
37
|
+
/**
|
|
38
|
+
* GA4 user_properties (from identify/group traits). Each value is wrapped
|
|
39
|
+
* in `{ value }` as the Measurement Protocol requires; the gtag adapter
|
|
40
|
+
* unwraps when it calls `gtag('set', 'user_properties', ...)`.
|
|
41
|
+
*/
|
|
42
|
+
userProperties?: Record<string, { value: unknown }>
|
|
43
|
+
/**
|
|
44
|
+
* The GA4 event to send. `identify` calls carry no event (they only set
|
|
45
|
+
* user_id / user_properties), so this is optional.
|
|
46
|
+
*/
|
|
47
|
+
event?: GA4Event
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const GA4_RESERVED_PREFIXES = ['google_', 'ga_', 'firebase_']
|
|
51
|
+
|
|
52
|
+
/** Lower_snake_case an arbitrary event/trait name for GA4. */
|
|
53
|
+
export function toGa4Name(name: string): string {
|
|
54
|
+
const snake = name
|
|
55
|
+
.trim()
|
|
56
|
+
.replace(/['"]/g, '')
|
|
57
|
+
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
58
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
59
|
+
.replace(/_+/g, '_')
|
|
60
|
+
.replace(/^_+|_+$/g, '')
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
// GA4 names must start with a letter and avoid reserved prefixes.
|
|
63
|
+
const safe = /^[a-z]/.test(snake) ? snake : `e_${snake}`
|
|
64
|
+
return GA4_RESERVED_PREFIXES.some((p) => safe.startsWith(p))
|
|
65
|
+
? `x_${safe}`
|
|
66
|
+
: safe
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Map a Segment call type + name to its GA4 event name. */
|
|
70
|
+
export function ga4EventName(ev: AnalyticsEvent): string | undefined {
|
|
71
|
+
switch (ev.type) {
|
|
72
|
+
case 'identify':
|
|
73
|
+
// identify only sets identity/user_properties — no event is emitted.
|
|
74
|
+
return undefined
|
|
75
|
+
case 'page':
|
|
76
|
+
return 'page_view'
|
|
77
|
+
case 'screen':
|
|
78
|
+
return 'screen_view'
|
|
79
|
+
case 'group':
|
|
80
|
+
return 'group'
|
|
81
|
+
case 'alias':
|
|
82
|
+
return 'alias'
|
|
83
|
+
case 'track':
|
|
84
|
+
return ev.event ? toGa4Name(ev.event) : 'track'
|
|
85
|
+
default:
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Wrap traits as GA4 user_properties (`{ name: { value } }`). */
|
|
91
|
+
function toUserProperties(
|
|
92
|
+
traits: AnalyticsProperties | undefined,
|
|
93
|
+
): Record<string, { value: unknown }> | undefined {
|
|
94
|
+
if (!traits) return undefined
|
|
95
|
+
const out: Record<string, { value: unknown }> = {}
|
|
96
|
+
let any = false
|
|
97
|
+
for (const [k, v] of Object.entries(traits)) {
|
|
98
|
+
if (v === undefined) continue
|
|
99
|
+
out[toGa4Name(k)] = { value: v }
|
|
100
|
+
any = true
|
|
101
|
+
}
|
|
102
|
+
return any ? out : undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Build the GA4 event params from a canonical envelope. */
|
|
106
|
+
function toParams(ev: AnalyticsEvent): Record<string, unknown> {
|
|
107
|
+
const params: Record<string, unknown> = {}
|
|
108
|
+
|
|
109
|
+
// Pass through the canonical payload as GA4 params.
|
|
110
|
+
for (const [k, v] of Object.entries(ev.properties ?? {})) {
|
|
111
|
+
if (v === undefined) continue
|
|
112
|
+
params[toGa4Name(k)] = v
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Align GA4 sessionisation with our analytics session.
|
|
116
|
+
params.session_id = ev.sessionId
|
|
117
|
+
// Stable client-supplied de-dupe / debugging aid.
|
|
118
|
+
params.engagement_time_msec = params.engagement_time_msec ?? 1
|
|
119
|
+
|
|
120
|
+
// page/screen context → GA4 page_* params (Enhanced-Measurement parity).
|
|
121
|
+
const page = ev.context.page
|
|
122
|
+
if (ev.type === 'page' || ev.type === 'screen') {
|
|
123
|
+
if (ev.event) {
|
|
124
|
+
if (ev.type === 'screen') params.screen_name = ev.event
|
|
125
|
+
else params.page_title = params.page_title ?? ev.event
|
|
126
|
+
}
|
|
127
|
+
if (page?.url && params.page_location === undefined) {
|
|
128
|
+
params.page_location = page.url
|
|
129
|
+
}
|
|
130
|
+
if (page?.path && params.page_path === undefined) {
|
|
131
|
+
params.page_path = page.path
|
|
132
|
+
}
|
|
133
|
+
if (page?.referrer && params.page_referrer === undefined) {
|
|
134
|
+
params.page_referrer = page.referrer
|
|
135
|
+
}
|
|
136
|
+
if (page?.title && params.page_title === undefined) {
|
|
137
|
+
params.page_title = page.title
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (ev.type === 'group' && ev.groupId) {
|
|
142
|
+
params.group_id = ev.groupId
|
|
143
|
+
}
|
|
144
|
+
if (ev.type === 'alias') {
|
|
145
|
+
if (ev.userId) params.user_id = ev.userId
|
|
146
|
+
if (ev.previousId) params.previous_id = ev.previousId
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return params
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Map one canonical envelope to its GA4 representation. Pure — no transport,
|
|
154
|
+
* no vendor lib; both the gtag and Measurement-Protocol adapters consume this.
|
|
155
|
+
*/
|
|
156
|
+
export function mapEvent(ev: AnalyticsEvent): GA4Mapped {
|
|
157
|
+
const name = ga4EventName(ev)
|
|
158
|
+
const mapped: GA4Mapped = {
|
|
159
|
+
clientId: ev.anonymousId,
|
|
160
|
+
}
|
|
161
|
+
if (ev.userId) mapped.userId = ev.userId
|
|
162
|
+
|
|
163
|
+
if (ev.type === 'identify' || ev.type === 'group') {
|
|
164
|
+
const up = toUserProperties(ev.traits)
|
|
165
|
+
if (up) mapped.userProperties = up
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (name) {
|
|
169
|
+
mapped.event = { name, params: toParams(ev) }
|
|
170
|
+
}
|
|
171
|
+
return mapped
|
|
172
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @refraction-ui/analytics-sink-ga4 — option contracts.
|
|
3
|
+
*
|
|
4
|
+
* GA4 is "just a sink" in the epic's model (no privileged engine). This
|
|
5
|
+
* adapter implements the `AnalyticsSink` SPI from `@refraction-ui/analytics`
|
|
6
|
+
* in two interchangeable modes:
|
|
7
|
+
*
|
|
8
|
+
* - `client-sdk` — lazy-loads gtag.js in the browser (no hard vendor dep;
|
|
9
|
+
* the script is injected on first delivery only). Bridges Consent Mode.
|
|
10
|
+
* - `http` — GA4 Measurement Protocol (`/mp/collect`). No browser
|
|
11
|
+
* library is ever loaded — server-relay friendly.
|
|
12
|
+
*
|
|
13
|
+
* Default = `http` (protocol adapter, no vendor lib in the browser), matching
|
|
14
|
+
* the epic's "default = protocol adapters" stance.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Minimal gtag.js function signature (we never import the real types). */
|
|
18
|
+
export type GtagFn = (...args: unknown[]) => void
|
|
19
|
+
|
|
20
|
+
/** GA4 Consent Mode signal values. */
|
|
21
|
+
export type ConsentState = 'granted' | 'denied'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* GA4 Consent Mode bridge — maps our consent categories to the gtag
|
|
25
|
+
* `consent` command. Only used in `client-sdk` mode.
|
|
26
|
+
*/
|
|
27
|
+
export interface GA4ConsentBridge {
|
|
28
|
+
/**
|
|
29
|
+
* Default consent state pushed via `gtag('consent', 'default', …)` before
|
|
30
|
+
* the GA4 tag loads. Keys are GA4 consent types
|
|
31
|
+
* (`analytics_storage`, `ad_storage`, `ad_user_data`,
|
|
32
|
+
* `ad_personalization`, `functionality_storage`, …).
|
|
33
|
+
*/
|
|
34
|
+
default?: Record<string, ConsentState>
|
|
35
|
+
/**
|
|
36
|
+
* Maps a refraction consent category → the GA4 consent types it controls.
|
|
37
|
+
* When the router reports the sink may deliver (category granted) the
|
|
38
|
+
* adapter pushes `gtag('consent', 'update', { <types>: 'granted' })`.
|
|
39
|
+
* Example: `{ analytics: ['analytics_storage'] }`.
|
|
40
|
+
*/
|
|
41
|
+
map?: Record<string, string[]>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface GA4CommonOptions {
|
|
45
|
+
/** GA4 Measurement ID, e.g. `G-XXXXXXXXXX`. */
|
|
46
|
+
measurementId: string
|
|
47
|
+
/**
|
|
48
|
+
* Consent categories this sink requires. The router will not deliver until
|
|
49
|
+
* all are granted. Default: `['analytics']`.
|
|
50
|
+
*/
|
|
51
|
+
consentCategories?: string[]
|
|
52
|
+
/** Stable sink name. Default `'ga4'`. */
|
|
53
|
+
name?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `client-sdk` mode — runs gtag.js in the browser. */
|
|
57
|
+
export interface GA4ClientSdkOptions extends GA4CommonOptions {
|
|
58
|
+
mode: 'client-sdk'
|
|
59
|
+
/**
|
|
60
|
+
* Inject the GA4 Consent Mode bridge. The default state (if any) is pushed
|
|
61
|
+
* before the tag loads; category grants drive `consent: update`.
|
|
62
|
+
*/
|
|
63
|
+
consentMode?: GA4ConsentBridge
|
|
64
|
+
/**
|
|
65
|
+
* Inject an existing gtag function (tests / apps that manage the tag
|
|
66
|
+
* themselves). When provided, the script loader is NOT used and **no**
|
|
67
|
+
* vendor script is injected.
|
|
68
|
+
*/
|
|
69
|
+
gtag?: GtagFn
|
|
70
|
+
/**
|
|
71
|
+
* Inject the script loader (tests / SSR-safe apps). Receives the gtag.js
|
|
72
|
+
* src URL; must resolve once the script has executed. Defaults to a DOM
|
|
73
|
+
* `<script>` injector (browser only).
|
|
74
|
+
*/
|
|
75
|
+
scriptLoader?: (src: string) => Promise<void>
|
|
76
|
+
/** Override the gtag.js base URL (testing). */
|
|
77
|
+
gtagSrcBase?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `http` mode — GA4 Measurement Protocol. No browser library. */
|
|
81
|
+
export interface GA4HttpOptions extends GA4CommonOptions {
|
|
82
|
+
mode?: 'http'
|
|
83
|
+
/** GA4 Measurement Protocol API secret (server-side credential). */
|
|
84
|
+
apiSecret: string
|
|
85
|
+
/** Override the `/mp/collect` base URL (testing / EU endpoint). */
|
|
86
|
+
endpoint?: string
|
|
87
|
+
/** Use the Measurement Protocol `/debug/mp/collect` validation endpoint. */
|
|
88
|
+
debug?: boolean
|
|
89
|
+
/** Injected fetch (defaults to global fetch). Never loads a vendor lib. */
|
|
90
|
+
fetchImpl?: typeof fetch
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Discriminated union of the two modes. */
|
|
94
|
+
export type GA4SinkOptions = GA4ClientSdkOptions | GA4HttpOptions
|