@refraction-ui/astro 0.6.0 → 0.8.0
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/package.json +6 -2
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnalyticsEvent,
|
|
3
|
+
AnalyticsSink,
|
|
4
|
+
SinkInitContext,
|
|
5
|
+
} from '../analytics/index.ts'
|
|
6
|
+
import { distinctId } from './mapping.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PostHog `client-sdk` sink — OPTIONAL, opt-in mode.
|
|
10
|
+
*
|
|
11
|
+
* For client-exclusive PostHog features (autocapture, feature flags,
|
|
12
|
+
* surveys, web experiments) that the protocol API alone cannot drive.
|
|
13
|
+
* `posthog-js` is loaded **lazily via dynamic `import()`** the first time the
|
|
14
|
+
* sink delivers, so a consumer who only uses the default `http` sink never
|
|
15
|
+
* pays the browser-library cost and `posthog-js` stays a fully optional peer.
|
|
16
|
+
*
|
|
17
|
+
* Session replay is deliberately NOT touched here — it lives in the separate
|
|
18
|
+
* `@refraction-ui/analytics-sink-posthog/replay` module so it is never on the
|
|
19
|
+
* event path and tree-shakes out unless explicitly enabled.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Minimal structural type for the bits of `posthog-js` we use. */
|
|
23
|
+
interface PostHogJs {
|
|
24
|
+
init(apiKey: string, options: Record<string, unknown>): void
|
|
25
|
+
capture(event: string, properties?: Record<string, unknown>): void
|
|
26
|
+
identify(distinctId: string, set?: Record<string, unknown>): void
|
|
27
|
+
alias(alias: string, original?: string): void
|
|
28
|
+
group(
|
|
29
|
+
groupType: string,
|
|
30
|
+
groupKey: string,
|
|
31
|
+
properties?: Record<string, unknown>,
|
|
32
|
+
): void
|
|
33
|
+
reset(): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PostHogClientSdkSinkOptions {
|
|
37
|
+
/** PostHog project API key. */
|
|
38
|
+
apiKey: string
|
|
39
|
+
/** PostHog API host. Default `https://us.i.posthog.com`. */
|
|
40
|
+
host?: string
|
|
41
|
+
/** Sink name. Default `posthog`. */
|
|
42
|
+
name?: string
|
|
43
|
+
/** Consent categories this sink requires. Default `['analytics']`. */
|
|
44
|
+
consentCategories?: string[]
|
|
45
|
+
/**
|
|
46
|
+
* Extra `posthog-js` init options. `autocapture`, `capture_pageview`, and
|
|
47
|
+
* `session_recording` default to OFF — the canonical router owns the event
|
|
48
|
+
* path; replay is opt-in via the separate module.
|
|
49
|
+
*/
|
|
50
|
+
posthogOptions?: Record<string, unknown>
|
|
51
|
+
/**
|
|
52
|
+
* Injected loader (testing / custom bundling). Defaults to a lazy
|
|
53
|
+
* `import('posthog-js')`.
|
|
54
|
+
*/
|
|
55
|
+
loadPostHog?: () => Promise<{ default: PostHogJs } | PostHogJs>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function defaultLoad(): Promise<PostHogJs> {
|
|
59
|
+
// Dynamic import keeps `posthog-js` out of the graph until first delivery.
|
|
60
|
+
const mod = (await import('posthog-js')) as unknown as
|
|
61
|
+
| { default: PostHogJs }
|
|
62
|
+
| PostHogJs
|
|
63
|
+
return 'default' in mod ? mod.default : mod
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create the OPTIONAL PostHog `client-sdk`-mode sink. `posthog-js` is
|
|
68
|
+
* dynamically imported on first use, never at module load.
|
|
69
|
+
*/
|
|
70
|
+
export function createPostHogClientSdkSink(
|
|
71
|
+
options: PostHogClientSdkSinkOptions,
|
|
72
|
+
): AnalyticsSink {
|
|
73
|
+
const {
|
|
74
|
+
apiKey,
|
|
75
|
+
host = 'https://us.i.posthog.com',
|
|
76
|
+
name = 'posthog',
|
|
77
|
+
consentCategories = ['analytics'],
|
|
78
|
+
posthogOptions = {},
|
|
79
|
+
} = options
|
|
80
|
+
|
|
81
|
+
let ph: PostHogJs | undefined
|
|
82
|
+
let loading: Promise<PostHogJs> | undefined
|
|
83
|
+
|
|
84
|
+
async function ensure(): Promise<PostHogJs> {
|
|
85
|
+
if (ph) return ph
|
|
86
|
+
if (!loading) {
|
|
87
|
+
const load = options.loadPostHog
|
|
88
|
+
? async () => {
|
|
89
|
+
const m = await options.loadPostHog!()
|
|
90
|
+
return ('default' in m ? m.default : m) as PostHogJs
|
|
91
|
+
}
|
|
92
|
+
: defaultLoad
|
|
93
|
+
loading = load().then((instance) => {
|
|
94
|
+
instance.init(apiKey, {
|
|
95
|
+
api_host: host,
|
|
96
|
+
// The canonical router owns the event path — disable PostHog's
|
|
97
|
+
// own auto-capture and pageview, and never start replay here.
|
|
98
|
+
autocapture: false,
|
|
99
|
+
capture_pageview: false,
|
|
100
|
+
disable_session_recording: true,
|
|
101
|
+
...posthogOptions,
|
|
102
|
+
})
|
|
103
|
+
ph = instance
|
|
104
|
+
return instance
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
return loading
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
name,
|
|
112
|
+
consentCategories,
|
|
113
|
+
|
|
114
|
+
async init(_ctx: SinkInitContext): Promise<void> {
|
|
115
|
+
// Eagerly warm the SDK so the first event is not delayed. Still lazy
|
|
116
|
+
// relative to module load — only runs once the sink is registered.
|
|
117
|
+
await ensure()
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async deliver(batch: AnalyticsEvent[]): Promise<void> {
|
|
121
|
+
if (batch.length === 0) return
|
|
122
|
+
const client = await ensure()
|
|
123
|
+
for (const ev of batch) {
|
|
124
|
+
const did = distinctId(ev)
|
|
125
|
+
switch (ev.type) {
|
|
126
|
+
case 'identify':
|
|
127
|
+
client.identify(did, { ...(ev.traits ?? {}) })
|
|
128
|
+
break
|
|
129
|
+
case 'alias':
|
|
130
|
+
if (ev.previousId) client.alias(ev.previousId, did)
|
|
131
|
+
break
|
|
132
|
+
case 'group':
|
|
133
|
+
client.group(
|
|
134
|
+
(ev.properties?.groupType as string) ?? 'company',
|
|
135
|
+
ev.groupId ?? '',
|
|
136
|
+
{ ...(ev.traits ?? {}) },
|
|
137
|
+
)
|
|
138
|
+
break
|
|
139
|
+
case 'page':
|
|
140
|
+
client.capture('$pageview', { ...(ev.properties ?? {}) })
|
|
141
|
+
break
|
|
142
|
+
case 'screen':
|
|
143
|
+
client.capture('$screen', {
|
|
144
|
+
$screen_name: ev.event,
|
|
145
|
+
...(ev.properties ?? {}),
|
|
146
|
+
})
|
|
147
|
+
break
|
|
148
|
+
case 'track':
|
|
149
|
+
default:
|
|
150
|
+
client.capture(ev.event ?? 'track', { ...(ev.properties ?? {}) })
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
shutdown(): void {
|
|
157
|
+
ph?.reset()
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnalyticsEvent,
|
|
3
|
+
AnalyticsSink,
|
|
4
|
+
SinkDeliverContext,
|
|
5
|
+
} from '../analytics/index.ts'
|
|
6
|
+
import { toPostHogBatch } from './mapping.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PostHog `http` sink — the DEFAULT mode.
|
|
10
|
+
*
|
|
11
|
+
* Talks to PostHog's ingestion API directly over `fetch`; it never loads
|
|
12
|
+
* `posthog-js` or any browser library, so it is server-relay friendly and
|
|
13
|
+
* ad-blocker-proof when fronted by your own endpoint.
|
|
14
|
+
*
|
|
15
|
+
* single event → POST {host}/capture/ { api_key, event, ... }
|
|
16
|
+
* batch → POST {host}/batch/ { api_key, batch: [...] }
|
|
17
|
+
*
|
|
18
|
+
* PostHog accept-and-queue semantics mirror the canonical wire contract:
|
|
19
|
+
* 2xx accepted (queued, not processed) → done
|
|
20
|
+
* 400 malformed → DROP, never retry
|
|
21
|
+
* 401 / 403 bad project key → DROP, never retry
|
|
22
|
+
* 413 payload too big → DROP (we also pre-split under the size caps)
|
|
23
|
+
* 429 / 5xx transient → exponential backoff retry
|
|
24
|
+
* network — → treated as transient → retry
|
|
25
|
+
*
|
|
26
|
+
* No `Authorization` header is used: PostHog authenticates with the public
|
|
27
|
+
* project API key carried in the JSON body, so the `sendBeacon` unload path
|
|
28
|
+
* works without any header juggling.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const NO_RETRY = new Set([400, 401, 403, 413])
|
|
32
|
+
|
|
33
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
|
34
|
+
|
|
35
|
+
function byteLength(s: string): number {
|
|
36
|
+
const g = globalThis as unknown as {
|
|
37
|
+
TextEncoder?: new () => { encode(s: string): { length: number } }
|
|
38
|
+
}
|
|
39
|
+
if (g.TextEncoder) return new g.TextEncoder().encode(s).length
|
|
40
|
+
return unescape(encodeURIComponent(s)).length
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PostHogHttpSinkOptions {
|
|
44
|
+
/** PostHog project API key (the public, write-only key). */
|
|
45
|
+
apiKey: string
|
|
46
|
+
/**
|
|
47
|
+
* Ingestion host. Default `https://us.i.posthog.com`. Use
|
|
48
|
+
* `https://eu.i.posthog.com`, a self-hosted host, or — recommended for
|
|
49
|
+
* production — your own reverse-proxy/relay path.
|
|
50
|
+
*/
|
|
51
|
+
host?: string
|
|
52
|
+
/** Sink name. Default `posthog`. */
|
|
53
|
+
name?: string
|
|
54
|
+
/** Consent categories this sink requires. Default `['analytics']`. */
|
|
55
|
+
consentCategories?: string[]
|
|
56
|
+
/** Max retries for 429/5xx (exponential backoff). Default 3. */
|
|
57
|
+
maxRetries?: number
|
|
58
|
+
/** Base backoff delay in ms. Default 500. */
|
|
59
|
+
backoffBaseMs?: number
|
|
60
|
+
/** Injected fetch (defaults to global fetch). */
|
|
61
|
+
fetchImpl?: typeof fetch
|
|
62
|
+
/** Injected sendBeacon (defaults to navigator.sendBeacon). */
|
|
63
|
+
beaconImpl?: (url: string, body: string) => boolean
|
|
64
|
+
/** Soft per-batch byte cap. PostHog limit ≈ 20MB; default 1MB. */
|
|
65
|
+
maxBatchBytes?: number
|
|
66
|
+
/** Soft per-event byte cap. Default 32KB. */
|
|
67
|
+
maxEventBytes?: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type PostHogBatchItem = ReturnType<typeof toPostHogBatch>[number]
|
|
71
|
+
|
|
72
|
+
/** Split so neither per-event nor per-batch byte caps are exceeded. */
|
|
73
|
+
function splitBatch(
|
|
74
|
+
items: PostHogBatchItem[],
|
|
75
|
+
maxBatchBytes: number,
|
|
76
|
+
maxEventBytes: number,
|
|
77
|
+
): PostHogBatchItem[][] {
|
|
78
|
+
const batches: PostHogBatchItem[][] = []
|
|
79
|
+
let current: PostHogBatchItem[] = []
|
|
80
|
+
let currentBytes = 2
|
|
81
|
+
|
|
82
|
+
for (const item of items) {
|
|
83
|
+
const itemBytes = byteLength(JSON.stringify(item))
|
|
84
|
+
// Oversized single events can never be sent — drop them.
|
|
85
|
+
if (itemBytes > maxEventBytes) continue
|
|
86
|
+
if (current.length && currentBytes + itemBytes + 1 > maxBatchBytes) {
|
|
87
|
+
batches.push(current)
|
|
88
|
+
current = []
|
|
89
|
+
currentBytes = 2
|
|
90
|
+
}
|
|
91
|
+
current.push(item)
|
|
92
|
+
currentBytes += itemBytes + 1
|
|
93
|
+
}
|
|
94
|
+
if (current.length) batches.push(current)
|
|
95
|
+
return batches
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create the PostHog `http`-mode sink (default). Pure protocol adapter —
|
|
100
|
+
* no `posthog-js`, safe in Node and the browser.
|
|
101
|
+
*/
|
|
102
|
+
export function createPostHogHttpSink(
|
|
103
|
+
options: PostHogHttpSinkOptions,
|
|
104
|
+
): AnalyticsSink {
|
|
105
|
+
const {
|
|
106
|
+
apiKey,
|
|
107
|
+
host = 'https://us.i.posthog.com',
|
|
108
|
+
name = 'posthog',
|
|
109
|
+
consentCategories = ['analytics'],
|
|
110
|
+
maxRetries = 3,
|
|
111
|
+
backoffBaseMs = 500,
|
|
112
|
+
maxBatchBytes = 1_000_000,
|
|
113
|
+
maxEventBytes = 32_000,
|
|
114
|
+
} = options
|
|
115
|
+
|
|
116
|
+
const base = host.replace(/\/+$/, '')
|
|
117
|
+
const captureUrl = `${base}/capture/`
|
|
118
|
+
const batchUrl = `${base}/batch/`
|
|
119
|
+
|
|
120
|
+
const resolveFetch = (): typeof fetch => {
|
|
121
|
+
if (options.fetchImpl) return options.fetchImpl
|
|
122
|
+
const f = (globalThis as unknown as { fetch?: typeof fetch }).fetch
|
|
123
|
+
if (!f) throw new Error('No fetch implementation available')
|
|
124
|
+
return f
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const resolveBeacon = ():
|
|
128
|
+
| ((u: string, body: string) => boolean)
|
|
129
|
+
| undefined => {
|
|
130
|
+
if (options.beaconImpl) return options.beaconImpl
|
|
131
|
+
const nav = (
|
|
132
|
+
globalThis as unknown as {
|
|
133
|
+
navigator?: { sendBeacon?: (u: string, data: BodyInit) => boolean }
|
|
134
|
+
}
|
|
135
|
+
).navigator
|
|
136
|
+
if (nav && typeof nav.sendBeacon === 'function') {
|
|
137
|
+
return (u, body) => nav.sendBeacon!(u, body)
|
|
138
|
+
}
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function payload(part: PostHogBatchItem[]): {
|
|
143
|
+
url: string
|
|
144
|
+
body: string
|
|
145
|
+
} {
|
|
146
|
+
if (part.length === 1) {
|
|
147
|
+
return {
|
|
148
|
+
url: captureUrl,
|
|
149
|
+
body: JSON.stringify({ api_key: apiKey, ...part[0] }),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
url: batchUrl,
|
|
154
|
+
body: JSON.stringify({ api_key: apiKey, batch: part }),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function sendViaFetch(part: PostHogBatchItem[]): Promise<void> {
|
|
159
|
+
const doFetch = resolveFetch()
|
|
160
|
+
const { url, body } = payload(part)
|
|
161
|
+
|
|
162
|
+
for (let attempt = 0; ; attempt++) {
|
|
163
|
+
let status: number
|
|
164
|
+
try {
|
|
165
|
+
const res = await doFetch(url, {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
headers: { 'Content-Type': 'application/json' },
|
|
168
|
+
body,
|
|
169
|
+
keepalive: true,
|
|
170
|
+
})
|
|
171
|
+
status = res.status
|
|
172
|
+
} catch {
|
|
173
|
+
status = 0 // network error → transient
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (status >= 200 && status < 300) return
|
|
177
|
+
if (NO_RETRY.has(status)) return
|
|
178
|
+
|
|
179
|
+
if (attempt >= maxRetries) return
|
|
180
|
+
await sleep(backoffBaseMs * 2 ** attempt)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function sendViaBeacon(part: PostHogBatchItem[]): boolean {
|
|
185
|
+
const beacon = resolveBeacon()
|
|
186
|
+
if (!beacon) return false
|
|
187
|
+
const { url, body } = payload(part)
|
|
188
|
+
return beacon(url, body)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
name,
|
|
193
|
+
consentCategories,
|
|
194
|
+
|
|
195
|
+
async deliver(
|
|
196
|
+
batch: AnalyticsEvent[],
|
|
197
|
+
ctx: SinkDeliverContext,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
if (batch.length === 0) return
|
|
200
|
+
const items = toPostHogBatch(batch)
|
|
201
|
+
const parts = splitBatch(items, maxBatchBytes, maxEventBytes)
|
|
202
|
+
|
|
203
|
+
for (const part of parts) {
|
|
204
|
+
if (part.length === 0) continue
|
|
205
|
+
if (ctx.unload) {
|
|
206
|
+
if (sendViaBeacon(part)) continue
|
|
207
|
+
void sendViaFetch(part)
|
|
208
|
+
} else {
|
|
209
|
+
await sendViaFetch(part)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @refraction-ui/analytics-sink-posthog
|
|
3
|
+
*
|
|
4
|
+
* A PostHog `AnalyticsSink` for `@refraction-ui/analytics`. PostHog is *just
|
|
5
|
+
* a sink* — register it via `config.sinks` or `analytics.addSink(...)`.
|
|
6
|
+
*
|
|
7
|
+
* - `http` mode (DEFAULT): pure protocol adapter against PostHog's
|
|
8
|
+
* `/capture` + `/batch` API. No `posthog-js`. Server-relay friendly.
|
|
9
|
+
* - `client-sdk` mode (OPTIONAL): lazily dynamic-imports `posthog-js`.
|
|
10
|
+
*
|
|
11
|
+
* Session replay is intentionally NOT exported here. Import the separate
|
|
12
|
+
* `@refraction-ui/analytics-sink-posthog/replay` module to opt in — it is
|
|
13
|
+
* never on the event path and tree-shakes away when unused.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export { createPostHogSink } from './sink.js'
|
|
17
|
+
export type { PostHogSinkMode, PostHogSinkOptions } from './sink.js'
|
|
18
|
+
|
|
19
|
+
export { createPostHogHttpSink } from './http-sink.js'
|
|
20
|
+
export type { PostHogHttpSinkOptions } from './http-sink.js'
|
|
21
|
+
|
|
22
|
+
export { createPostHogClientSdkSink } from './client-sdk-sink.js'
|
|
23
|
+
export type { PostHogClientSdkSinkOptions } from './client-sdk-sink.js'
|
|
24
|
+
|
|
25
|
+
export { toPostHogEvent, toPostHogBatch, distinctId } from './mapping.js'
|
|
26
|
+
export type { PostHogEvent } from './mapping.js'
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { AnalyticsEvent } from '../analytics/index.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical envelope → PostHog event mapping.
|
|
5
|
+
*
|
|
6
|
+
* This module is pure (no I/O, no transport) so the http sink and the
|
|
7
|
+
* client-sdk sink share exactly one mapping definition and it can be unit
|
|
8
|
+
* tested in isolation against a mock transport.
|
|
9
|
+
*
|
|
10
|
+
* PostHog identity model:
|
|
11
|
+
* - `distinct_id` is the single id PostHog buckets a person under.
|
|
12
|
+
* - Before `identify`, the anonymous visitor is keyed by `anonymousId`.
|
|
13
|
+
* - `identify` upgrades the person to the opaque app `userId`, sending
|
|
14
|
+
* `$set` traits and an `$anon_distinct_id` so PostHog stitches the
|
|
15
|
+
* pre-identify anonymous history onto the identified person.
|
|
16
|
+
* - `alias` emits PostHog's `$create_alias` linking `previousId` (alias)
|
|
17
|
+
* to the canonical `userId`/`anonymousId` (distinct_id).
|
|
18
|
+
* - `group` emits a `$groupidentify` event with `$group_set` traits.
|
|
19
|
+
*
|
|
20
|
+
* See https://posthog.com/docs/api/capture for the event shape.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A single PostHog capture event (the `/capture` and `/batch` item shape). */
|
|
24
|
+
export interface PostHogEvent {
|
|
25
|
+
/** PostHog event name (Segment names map to `$pageview`/`$screen`/etc.). */
|
|
26
|
+
event: string
|
|
27
|
+
/** The person bucket id. */
|
|
28
|
+
distinct_id: string
|
|
29
|
+
/** Event + person/group properties. */
|
|
30
|
+
properties: Record<string, unknown>
|
|
31
|
+
/** ISO-8601 client timestamp (PostHog corrects for skew server-side). */
|
|
32
|
+
timestamp: string
|
|
33
|
+
/** Idempotency key — PostHog dedupes on this. Mirrors `messageId`. */
|
|
34
|
+
uuid: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve the PostHog `distinct_id` for an envelope. */
|
|
38
|
+
export function distinctId(ev: AnalyticsEvent): string {
|
|
39
|
+
return ev.userId ?? ev.anonymousId
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Properties that PostHog reads off `context` for every event so its UI
|
|
44
|
+
* shows app/library/page/session metadata without the consumer wiring it.
|
|
45
|
+
*/
|
|
46
|
+
function contextProperties(ev: AnalyticsEvent): Record<string, unknown> {
|
|
47
|
+
const ctx = ev.context
|
|
48
|
+
const props: Record<string, unknown> = {
|
|
49
|
+
$lib: ctx.library?.name,
|
|
50
|
+
$lib_version: ctx.library?.version,
|
|
51
|
+
app: ctx.app,
|
|
52
|
+
env: ctx.env,
|
|
53
|
+
$session_id: ev.sessionId,
|
|
54
|
+
// Keep the canonical anonymous id addressable in PostHog too.
|
|
55
|
+
anonymousId: ev.anonymousId,
|
|
56
|
+
}
|
|
57
|
+
const page = ctx.page
|
|
58
|
+
if (page) {
|
|
59
|
+
if (page.url !== undefined) props.$current_url = page.url
|
|
60
|
+
if (page.path !== undefined) props.$pathname = page.path
|
|
61
|
+
if (page.referrer !== undefined) props.$referrer = page.referrer
|
|
62
|
+
if (page.title !== undefined) props.title = page.title
|
|
63
|
+
if (page.search !== undefined) props.$search = page.search
|
|
64
|
+
}
|
|
65
|
+
return props
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Map one canonical envelope to one PostHog event.
|
|
70
|
+
*
|
|
71
|
+
* `track` → the event name verbatim, `properties` passed through.
|
|
72
|
+
* `page` → `$pageview` (PostHog's built-in pageview).
|
|
73
|
+
* `screen` → `$screen` with `$screen_name`.
|
|
74
|
+
* `identify` → `$identify` with `$set` traits + `$anon_distinct_id` stitch.
|
|
75
|
+
* `group` → `$groupidentify` with `$group_set` traits.
|
|
76
|
+
* `alias` → `$create_alias` linking `previousId` → distinct id.
|
|
77
|
+
*/
|
|
78
|
+
export function toPostHogEvent(ev: AnalyticsEvent): PostHogEvent {
|
|
79
|
+
const base = {
|
|
80
|
+
distinct_id: distinctId(ev),
|
|
81
|
+
timestamp: ev.timestamp,
|
|
82
|
+
uuid: ev.messageId,
|
|
83
|
+
}
|
|
84
|
+
const props = contextProperties(ev)
|
|
85
|
+
|
|
86
|
+
switch (ev.type) {
|
|
87
|
+
case 'identify': {
|
|
88
|
+
return {
|
|
89
|
+
...base,
|
|
90
|
+
event: '$identify',
|
|
91
|
+
properties: {
|
|
92
|
+
...props,
|
|
93
|
+
$set: { ...(ev.traits ?? {}) },
|
|
94
|
+
// Stitch the pre-identify anonymous history onto this person.
|
|
95
|
+
$anon_distinct_id: ev.anonymousId,
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
case 'group': {
|
|
100
|
+
const groupType = (ev.properties?.groupType as string) ?? 'company'
|
|
101
|
+
return {
|
|
102
|
+
...base,
|
|
103
|
+
event: '$groupidentify',
|
|
104
|
+
properties: {
|
|
105
|
+
...props,
|
|
106
|
+
$group_type: groupType,
|
|
107
|
+
$group_key: ev.groupId,
|
|
108
|
+
$group_set: { ...(ev.traits ?? {}) },
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
case 'alias': {
|
|
113
|
+
return {
|
|
114
|
+
...base,
|
|
115
|
+
event: '$create_alias',
|
|
116
|
+
properties: {
|
|
117
|
+
...props,
|
|
118
|
+
// PostHog links `alias` to the current `distinct_id`.
|
|
119
|
+
alias: ev.previousId,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
case 'page': {
|
|
124
|
+
return {
|
|
125
|
+
...base,
|
|
126
|
+
event: '$pageview',
|
|
127
|
+
properties: { ...props, ...(ev.properties ?? {}) },
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
case 'screen': {
|
|
131
|
+
return {
|
|
132
|
+
...base,
|
|
133
|
+
event: '$screen',
|
|
134
|
+
properties: {
|
|
135
|
+
...props,
|
|
136
|
+
$screen_name: ev.event,
|
|
137
|
+
...(ev.properties ?? {}),
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
case 'track':
|
|
142
|
+
default: {
|
|
143
|
+
return {
|
|
144
|
+
...base,
|
|
145
|
+
event: ev.event ?? 'track',
|
|
146
|
+
properties: { ...props, ...(ev.properties ?? {}) },
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Map a batch of canonical envelopes to PostHog events. */
|
|
153
|
+
export function toPostHogBatch(batch: AnalyticsEvent[]): PostHogEvent[] {
|
|
154
|
+
return batch.map(toPostHogEvent)
|
|
155
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @refraction-ui/analytics-sink-posthog/replay
|
|
3
|
+
*
|
|
4
|
+
* OPTIONAL, lazy session-replay (rrweb, via `posthog-js`).
|
|
5
|
+
*
|
|
6
|
+
* Hard guarantees this module exists to enforce:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Off by default.** Nothing in the main `@refraction-ui/analytics-sink-posthog`
|
|
9
|
+
* entry imports this file, so it (and `posthog-js`, and rrweb) are fully
|
|
10
|
+
* tree-shaken out of any bundle that does not explicitly import it.
|
|
11
|
+
* 2. **Never on the event path.** Replay is not an `AnalyticsSink`. It does
|
|
12
|
+
* not see, transform, or block canonical envelopes. `track`/`identify`/…
|
|
13
|
+
* keep flowing through the sink even if replay never starts or fails.
|
|
14
|
+
* 3. **Privacy/consent gated.** `startSessionReplay` refuses to start unless
|
|
15
|
+
* a consent predicate returns true, and re-checks it; `stop()` tears the
|
|
16
|
+
* recorder down. Masking defaults are maximally private.
|
|
17
|
+
* 4. **Lazy.** `posthog-js` is `import()`-ed only when `startSessionReplay`
|
|
18
|
+
* is actually called.
|
|
19
|
+
*
|
|
20
|
+
* This is a thin controller around `posthog-js`'s built-in rrweb session
|
|
21
|
+
* recording — we do not bundle rrweb ourselves.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Minimal structural view of the `posthog-js` replay surface. */
|
|
25
|
+
interface PostHogReplay {
|
|
26
|
+
init(apiKey: string, options: Record<string, unknown>): void
|
|
27
|
+
startSessionRecording(): void
|
|
28
|
+
stopSessionRecording(): void
|
|
29
|
+
sessionRecordingStarted?(): boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SessionReplayOptions {
|
|
33
|
+
/** PostHog project API key. */
|
|
34
|
+
apiKey: string
|
|
35
|
+
/** PostHog API host. Default `https://us.i.posthog.com`. */
|
|
36
|
+
host?: string
|
|
37
|
+
/**
|
|
38
|
+
* Consent predicate. Replay starts ONLY when this returns `true`, and is
|
|
39
|
+
* re-checked by `enforceConsent()`. There is no default-allow: if omitted,
|
|
40
|
+
* replay is treated as NOT consented and will not start.
|
|
41
|
+
*/
|
|
42
|
+
hasConsent?: () => boolean
|
|
43
|
+
/**
|
|
44
|
+
* rrweb masking. Defaults are maximally private: all text + all inputs
|
|
45
|
+
* masked. Override deliberately and document the privacy review.
|
|
46
|
+
*/
|
|
47
|
+
maskAllText?: boolean
|
|
48
|
+
maskAllInputs?: boolean
|
|
49
|
+
/** Extra `posthog-js` session_recording options (advanced). */
|
|
50
|
+
recordingOptions?: Record<string, unknown>
|
|
51
|
+
/**
|
|
52
|
+
* Injected loader (testing / custom bundling). Defaults to a lazy
|
|
53
|
+
* `import('posthog-js')`.
|
|
54
|
+
*/
|
|
55
|
+
loadPostHog?: () => Promise<{ default: PostHogReplay } | PostHogReplay>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Handle returned by {@link startSessionReplay}. */
|
|
59
|
+
export interface SessionReplayHandle {
|
|
60
|
+
/** True if the rrweb recorder is currently running. */
|
|
61
|
+
readonly recording: boolean
|
|
62
|
+
/**
|
|
63
|
+
* Re-evaluate the consent predicate. If consent was revoked, recording is
|
|
64
|
+
* stopped. Call this from your consent-change handler.
|
|
65
|
+
*/
|
|
66
|
+
enforceConsent(): void
|
|
67
|
+
/** Stop recording and release the recorder. Idempotent. */
|
|
68
|
+
stop(): void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function defaultLoad(): Promise<PostHogReplay> {
|
|
72
|
+
const mod = (await import('posthog-js')) as unknown as
|
|
73
|
+
| { default: PostHogReplay }
|
|
74
|
+
| PostHogReplay
|
|
75
|
+
return 'default' in mod ? mod.default : mod
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Start PostHog/rrweb session replay. Resolves to a handle, or to a
|
|
80
|
+
* non-recording handle if consent is not granted (it never throws on the
|
|
81
|
+
* consent path — replay simply does not start).
|
|
82
|
+
*
|
|
83
|
+
* `posthog-js` is dynamically imported here and nowhere else.
|
|
84
|
+
*/
|
|
85
|
+
export async function startSessionReplay(
|
|
86
|
+
options: SessionReplayOptions,
|
|
87
|
+
): Promise<SessionReplayHandle> {
|
|
88
|
+
const {
|
|
89
|
+
apiKey,
|
|
90
|
+
host = 'https://us.i.posthog.com',
|
|
91
|
+
hasConsent,
|
|
92
|
+
maskAllText = true,
|
|
93
|
+
maskAllInputs = true,
|
|
94
|
+
recordingOptions = {},
|
|
95
|
+
} = options
|
|
96
|
+
|
|
97
|
+
const consented = (): boolean =>
|
|
98
|
+
typeof hasConsent === 'function' ? hasConsent() === true : false
|
|
99
|
+
|
|
100
|
+
// Single-slot holder so the closure can observe the lazily-loaded
|
|
101
|
+
// instance without an outer `let` rebind.
|
|
102
|
+
const slot: { ph?: PostHogReplay } = {}
|
|
103
|
+
let recording = false
|
|
104
|
+
|
|
105
|
+
const stop = (): void => {
|
|
106
|
+
if (slot.ph && recording) {
|
|
107
|
+
slot.ph.stopSessionRecording()
|
|
108
|
+
}
|
|
109
|
+
recording = false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const handle: SessionReplayHandle = {
|
|
113
|
+
get recording() {
|
|
114
|
+
return recording
|
|
115
|
+
},
|
|
116
|
+
enforceConsent(): void {
|
|
117
|
+
if (!consented()) stop()
|
|
118
|
+
},
|
|
119
|
+
stop,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Privacy gate: do not even load posthog-js if consent is absent.
|
|
123
|
+
if (!consented()) return handle
|
|
124
|
+
|
|
125
|
+
const load = options.loadPostHog
|
|
126
|
+
? async () => {
|
|
127
|
+
const m = await options.loadPostHog!()
|
|
128
|
+
return ('default' in m ? m.default : m) as PostHogReplay
|
|
129
|
+
}
|
|
130
|
+
: defaultLoad
|
|
131
|
+
|
|
132
|
+
slot.ph = await load()
|
|
133
|
+
slot.ph.init(apiKey, {
|
|
134
|
+
api_host: host,
|
|
135
|
+
autocapture: false,
|
|
136
|
+
capture_pageview: false,
|
|
137
|
+
// Start with replay disabled, then opt in explicitly below.
|
|
138
|
+
disable_session_recording: true,
|
|
139
|
+
session_recording: {
|
|
140
|
+
maskAllInputs,
|
|
141
|
+
maskTextSelector: maskAllText ? '*' : undefined,
|
|
142
|
+
...recordingOptions,
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Re-check consent after the (async) load in case it was revoked meanwhile.
|
|
147
|
+
if (!consented()) return handle
|
|
148
|
+
|
|
149
|
+
slot.ph.startSessionRecording()
|
|
150
|
+
recording = slot.ph.sessionRecordingStarted?.() ?? true
|
|
151
|
+
return handle
|
|
152
|
+
}
|