@refraction-ui/astro 0.5.1 → 0.7.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/analytics-manager.ts +361 -0
- package/dist/analytics/consent.ts +40 -0
- package/dist/analytics/console-sink.ts +37 -0
- package/dist/analytics/http-sink.ts +213 -0
- package/dist/analytics/identity.ts +64 -0
- package/dist/analytics/index.ts +60 -0
- package/dist/analytics/mock-sink.ts +62 -0
- package/dist/analytics/noop.ts +48 -0
- package/dist/analytics/redaction.ts +93 -0
- package/dist/analytics/session.ts +162 -0
- package/dist/analytics/storage.ts +119 -0
- package/dist/analytics/types.ts +273 -0
- package/dist/analytics/uuid.ts +63 -0
- package/dist/astro-analytics/AnalyticsScript.astro +130 -0
- package/dist/astro-analytics/index.ts +49 -0
- package/dist/astro-logger/TelemetryScript.astro +108 -0
- package/dist/astro-logger/index.ts +48 -0
- package/dist/astro-logger/library-error-capture.ts +121 -0
- package/dist/astro-logger/telemetry-middleware.ts +92 -0
- package/dist/index.ts +2 -0
- package/dist/logger/console-sink.ts +64 -0
- package/dist/logger/faro-engine.ts +130 -0
- package/dist/logger/index.ts +39 -0
- package/dist/logger/mock-sink.ts +38 -0
- package/dist/logger/noop.ts +52 -0
- package/dist/logger/presets.ts +51 -0
- package/dist/logger/redact.ts +33 -0
- package/dist/logger/telemetry-manager.ts +229 -0
- package/dist/logger/types.ts +121 -0
- package/dist/shared/dev-feedback.ts +154 -0
- package/dist/shared/index.ts +25 -0
- package/dist/shared/library-origin-error.ts +240 -0
- package/package.json +4 -2
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Analytics,
|
|
3
|
+
AnalyticsConfig,
|
|
4
|
+
AnalyticsContext,
|
|
5
|
+
AnalyticsEvent,
|
|
6
|
+
AnalyticsEventType,
|
|
7
|
+
AnalyticsSink,
|
|
8
|
+
CallOptions,
|
|
9
|
+
SinkDeliverContext,
|
|
10
|
+
} from './types.js'
|
|
11
|
+
import { SCHEMA_VERSION } from './types.js'
|
|
12
|
+
import { uuidv4 } from './uuid.js'
|
|
13
|
+
import { createSession, campaignFingerprint } from './session.js'
|
|
14
|
+
import { createIdentity } from './identity.js'
|
|
15
|
+
import { createConsent } from './consent.js'
|
|
16
|
+
import { createRedactor } from './redaction.js'
|
|
17
|
+
import { createHttpSink } from './http-sink.js'
|
|
18
|
+
import { createConsoleSink } from './console-sink.js'
|
|
19
|
+
import { createNoopAnalytics } from './noop.js'
|
|
20
|
+
|
|
21
|
+
const LIBRARY = {
|
|
22
|
+
name: '@refraction-ui/analytics',
|
|
23
|
+
version: '0.1.0',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Read the current page block from the DOM, if any (browser-only). */
|
|
27
|
+
function readPage(): AnalyticsContext['page'] | undefined {
|
|
28
|
+
const g = globalThis as unknown as {
|
|
29
|
+
location?: { pathname?: string; href?: string; search?: string }
|
|
30
|
+
document?: { title?: string; referrer?: string }
|
|
31
|
+
}
|
|
32
|
+
if (!g.location && !g.document) return undefined
|
|
33
|
+
return {
|
|
34
|
+
path: g.location?.pathname,
|
|
35
|
+
url: g.location?.href,
|
|
36
|
+
search: g.location?.search,
|
|
37
|
+
title: g.document?.title,
|
|
38
|
+
referrer: g.document?.referrer,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* createAnalytics — the neutral Segment-spec collector/router.
|
|
44
|
+
*
|
|
45
|
+
* Mirrors `createAI`: a single factory returns the entire public surface and
|
|
46
|
+
* fans the canonical envelope out to registered sinks. There is NO privileged
|
|
47
|
+
* engine — the built-in HTTP sink and every vendor adapter are equal sinks.
|
|
48
|
+
*
|
|
49
|
+
* Presets:
|
|
50
|
+
* dev — synchronous delivery + a console sink (see exactly what ships).
|
|
51
|
+
* prod — batching + sampling + beacon flush on pagehide/visibilitychange.
|
|
52
|
+
*
|
|
53
|
+
* When `enabled: false`, a tree-shakeable noop is returned and none of the
|
|
54
|
+
* live collector / sink code is reachable.
|
|
55
|
+
*/
|
|
56
|
+
export function createAnalytics(config: AnalyticsConfig): Analytics {
|
|
57
|
+
if (config.enabled === false) {
|
|
58
|
+
return createNoopAnalytics()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { app, env } = config
|
|
62
|
+
const preset =
|
|
63
|
+
config.preset ?? (env === 'production' ? 'prod' : 'dev')
|
|
64
|
+
const sampleRate = config.sampleRate ?? 1
|
|
65
|
+
const batchSize = config.batchSize ?? 20
|
|
66
|
+
const flushIntervalMs = config.flushIntervalMs ?? 10_000
|
|
67
|
+
|
|
68
|
+
const session = createSession(config.session)
|
|
69
|
+
const identity = createIdentity(config.identity)
|
|
70
|
+
const consent = createConsent(config.consent)
|
|
71
|
+
const redactor = createRedactor(config.redactKeys)
|
|
72
|
+
|
|
73
|
+
// --- Sink registry ------------------------------------------------------
|
|
74
|
+
const sinks = new Map<string, AnalyticsSink>()
|
|
75
|
+
const sinkOrder: string[] = []
|
|
76
|
+
const initialized = new Set<string>()
|
|
77
|
+
|
|
78
|
+
function registerSink(sink: AnalyticsSink): void {
|
|
79
|
+
if (!sinks.has(sink.name)) sinkOrder.push(sink.name)
|
|
80
|
+
sinks.set(sink.name, sink)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Auto HTTP sink when an endpoint is supplied.
|
|
84
|
+
if (config.endpoint) {
|
|
85
|
+
registerSink(
|
|
86
|
+
createHttpSink({
|
|
87
|
+
endpoint: config.endpoint,
|
|
88
|
+
writeKey: config.writeKey ?? '',
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
// dev preset → add a console sink for visibility.
|
|
93
|
+
if (preset === 'dev') {
|
|
94
|
+
registerSink(createConsoleSink())
|
|
95
|
+
}
|
|
96
|
+
// Explicit sinks (override same-named built-ins).
|
|
97
|
+
for (const s of config.sinks ?? []) registerSink(s)
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Initialise a sink at most once. Returns a promise only when the sink's
|
|
101
|
+
* own `init` is async — synchronous sinks stay on the synchronous path so
|
|
102
|
+
* the dev preset delivers without a microtask hop.
|
|
103
|
+
*/
|
|
104
|
+
function ensureInit(sink: AnalyticsSink): void | Promise<void> {
|
|
105
|
+
if (initialized.has(sink.name)) return
|
|
106
|
+
initialized.add(sink.name)
|
|
107
|
+
if (sink.init) {
|
|
108
|
+
return sink.init({ app, env, endpoint: config.endpoint })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- Batch buffer -------------------------------------------------------
|
|
113
|
+
const buffer: AnalyticsEvent[] = []
|
|
114
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
115
|
+
|
|
116
|
+
function startTimer(): void {
|
|
117
|
+
if (preset !== 'prod' || timer) return
|
|
118
|
+
timer = setInterval(() => {
|
|
119
|
+
void flush(false)
|
|
120
|
+
}, flushIntervalMs)
|
|
121
|
+
// Do not keep a Node process alive purely for the flush timer.
|
|
122
|
+
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Fan a batch out to every consented sink. Stays fully synchronous when all
|
|
127
|
+
* sinks are synchronous (dev preset visibility); returns a promise only if a
|
|
128
|
+
* sink's init/deliver is async.
|
|
129
|
+
*/
|
|
130
|
+
function deliverToSinks(
|
|
131
|
+
batch: AnalyticsEvent[],
|
|
132
|
+
unload: boolean,
|
|
133
|
+
): void | Promise<void> {
|
|
134
|
+
if (batch.length === 0) return
|
|
135
|
+
const ctx: SinkDeliverContext = { unload }
|
|
136
|
+
const pending: Array<Promise<unknown>> = []
|
|
137
|
+
for (const name of sinkOrder) {
|
|
138
|
+
const sink = sinks.get(name)
|
|
139
|
+
if (!sink) continue
|
|
140
|
+
if (!consent.allows(sink.consentCategories)) continue
|
|
141
|
+
const inited = ensureInit(sink)
|
|
142
|
+
if (inited && typeof (inited as Promise<void>).then === 'function') {
|
|
143
|
+
pending.push(
|
|
144
|
+
(inited as Promise<void>).then(() => sink.deliver(batch, ctx)),
|
|
145
|
+
)
|
|
146
|
+
} else {
|
|
147
|
+
const r = sink.deliver(batch, ctx)
|
|
148
|
+
if (r && typeof (r as Promise<void>).then === 'function') {
|
|
149
|
+
pending.push(r as Promise<void>)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (pending.length) return Promise.all(pending).then(() => undefined)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function flush(unload = false): Promise<void> {
|
|
157
|
+
const batch = buffer.splice(0, buffer.length)
|
|
158
|
+
await deliverToSinks(batch, unload)
|
|
159
|
+
for (const name of sinkOrder) {
|
|
160
|
+
const sink = sinks.get(name)
|
|
161
|
+
if (sink?.flush && consent.allows(sink.consentCategories)) {
|
|
162
|
+
await sink.flush()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- Unload flush (prod) ------------------------------------------------
|
|
168
|
+
function bindUnload(): void {
|
|
169
|
+
if (preset !== 'prod') return
|
|
170
|
+
const g = globalThis as unknown as {
|
|
171
|
+
addEventListener?: (t: string, h: () => void) => void
|
|
172
|
+
document?: { visibilityState?: string }
|
|
173
|
+
}
|
|
174
|
+
if (typeof g.addEventListener !== 'function') return
|
|
175
|
+
const onUnload = (): void => {
|
|
176
|
+
void deliverToSinks(buffer.splice(0, buffer.length), true)
|
|
177
|
+
}
|
|
178
|
+
g.addEventListener('pagehide', onUnload)
|
|
179
|
+
g.addEventListener('visibilitychange', () => {
|
|
180
|
+
if (g.document?.visibilityState === 'hidden') onUnload()
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
startTimer()
|
|
185
|
+
bindUnload()
|
|
186
|
+
|
|
187
|
+
// --- Envelope construction ---------------------------------------------
|
|
188
|
+
function buildContext(
|
|
189
|
+
extra?: Partial<AnalyticsContext>,
|
|
190
|
+
childCtx?: Partial<AnalyticsContext>,
|
|
191
|
+
): AnalyticsContext {
|
|
192
|
+
const page = readPage()
|
|
193
|
+
return {
|
|
194
|
+
app,
|
|
195
|
+
env,
|
|
196
|
+
...(page ? { page } : {}),
|
|
197
|
+
...childCtx,
|
|
198
|
+
...extra,
|
|
199
|
+
library: LIBRARY,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function sampled(): boolean {
|
|
204
|
+
if (sampleRate >= 1) return true
|
|
205
|
+
if (sampleRate <= 0) return false
|
|
206
|
+
return Math.random() < sampleRate
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function enqueue(ev: AnalyticsEvent): void {
|
|
210
|
+
if (preset === 'dev') {
|
|
211
|
+
// Synchronous, unbatched — immediate visibility.
|
|
212
|
+
void deliverToSinks([ev], false)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
buffer.push(ev)
|
|
216
|
+
if (buffer.length >= batchSize) {
|
|
217
|
+
void flush(false)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function emit(
|
|
222
|
+
type: AnalyticsEventType,
|
|
223
|
+
fields: Partial<AnalyticsEvent>,
|
|
224
|
+
childCtx: Partial<AnalyticsContext> | undefined,
|
|
225
|
+
opts?: CallOptions,
|
|
226
|
+
): void {
|
|
227
|
+
if (!sampled()) return
|
|
228
|
+
|
|
229
|
+
const page = readPage()
|
|
230
|
+
const campaign = campaignFingerprint(page?.search)
|
|
231
|
+
const sessionId = session.touch(campaign)
|
|
232
|
+
const sessionProps = session.props()
|
|
233
|
+
|
|
234
|
+
const ev: AnalyticsEvent = {
|
|
235
|
+
type,
|
|
236
|
+
messageId: uuidv4(),
|
|
237
|
+
anonymousId: identity.anonymousId(),
|
|
238
|
+
userId: identity.userId(),
|
|
239
|
+
sessionId,
|
|
240
|
+
context: buildContext(opts?.context, childCtx),
|
|
241
|
+
timestamp: opts?.timestamp ?? new Date().toISOString(),
|
|
242
|
+
schemaVersion: SCHEMA_VERSION,
|
|
243
|
+
...fields,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Merge session-scoped props beneath call props.
|
|
247
|
+
if (sessionProps && (ev.properties || type === 'track' || type === 'page' || type === 'screen')) {
|
|
248
|
+
ev.properties = { ...sessionProps, ...(ev.properties ?? {}) }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
enqueue(ev)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function makeApi(childCtx?: Partial<AnalyticsContext>): Analytics {
|
|
255
|
+
const api: Analytics = {
|
|
256
|
+
track(event, properties, opts) {
|
|
257
|
+
emit(
|
|
258
|
+
'track',
|
|
259
|
+
{ event, properties: redactor.redact(properties) },
|
|
260
|
+
childCtx,
|
|
261
|
+
opts,
|
|
262
|
+
)
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
identify(userId, traits, opts) {
|
|
266
|
+
identity.setUserId(userId)
|
|
267
|
+
emit('identify', { traits: redactor.redact(traits) }, childCtx, opts)
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
page(name, properties, opts) {
|
|
271
|
+
emit(
|
|
272
|
+
'page',
|
|
273
|
+
{ event: name, properties: redactor.redact(properties) },
|
|
274
|
+
childCtx,
|
|
275
|
+
opts,
|
|
276
|
+
)
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
screen(name, properties, opts) {
|
|
280
|
+
emit(
|
|
281
|
+
'screen',
|
|
282
|
+
{ event: name, properties: redactor.redact(properties) },
|
|
283
|
+
childCtx,
|
|
284
|
+
opts,
|
|
285
|
+
)
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
group(groupId, traits, opts) {
|
|
289
|
+
emit(
|
|
290
|
+
'group',
|
|
291
|
+
{ groupId, traits: redactor.redact(traits) },
|
|
292
|
+
childCtx,
|
|
293
|
+
opts,
|
|
294
|
+
)
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
alias(userId, previousId, opts) {
|
|
298
|
+
const stitch = identity.alias(userId, previousId)
|
|
299
|
+
emit(
|
|
300
|
+
'alias',
|
|
301
|
+
{ userId: stitch.userId, previousId: stitch.previousId },
|
|
302
|
+
childCtx,
|
|
303
|
+
opts,
|
|
304
|
+
)
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
session: {
|
|
308
|
+
id: () => session.id(),
|
|
309
|
+
start: () => session.start(),
|
|
310
|
+
end: () => session.end(),
|
|
311
|
+
set: (props) => session.set(props),
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
consent: {
|
|
315
|
+
grant: (...c) => consent.grant(...c),
|
|
316
|
+
revoke: (...c) => consent.revoke(...c),
|
|
317
|
+
granted: () => consent.granted(),
|
|
318
|
+
isGranted: (c) => consent.isGranted(c),
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
anonymousId: () => identity.anonymousId(),
|
|
322
|
+
userId: () => identity.userId(),
|
|
323
|
+
|
|
324
|
+
with(extra: Partial<AnalyticsContext>): Analytics {
|
|
325
|
+
return makeApi({ ...childCtx, ...extra })
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
addSink(sink: AnalyticsSink): void {
|
|
329
|
+
registerSink(sink)
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
removeSink(name: string): void {
|
|
333
|
+
if (sinks.has(name)) {
|
|
334
|
+
sinks.delete(name)
|
|
335
|
+
const i = sinkOrder.indexOf(name)
|
|
336
|
+
if (i !== -1) sinkOrder.splice(i, 1)
|
|
337
|
+
initialized.delete(name)
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
get sinks(): string[] {
|
|
342
|
+
return [...sinkOrder]
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
async flush(): Promise<void> {
|
|
346
|
+
await flush(false)
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
reset(): void {
|
|
350
|
+
identity.reset()
|
|
351
|
+
session.end()
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
enabled: true,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return api
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return makeApi()
|
|
361
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ConsentAPI, ConsentConfig } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Consent gate.
|
|
5
|
+
*
|
|
6
|
+
* Holds the set of granted consent categories. The router asks the gate, per
|
|
7
|
+
* sink, whether *all* of that sink's required categories are granted before
|
|
8
|
+
* delivering. A sink with no declared categories is always allowed (it is the
|
|
9
|
+
* sink author's responsibility to declare what it needs).
|
|
10
|
+
*/
|
|
11
|
+
export function createConsent(config?: ConsentConfig): ConsentAPI & {
|
|
12
|
+
/** True when every required category is granted (empty list ⇒ allowed). */
|
|
13
|
+
allows(required?: string[]): boolean
|
|
14
|
+
/** True when at least one sink could receive (used by strict mode). */
|
|
15
|
+
strict: boolean
|
|
16
|
+
} {
|
|
17
|
+
const granted = new Set<string>(config?.granted ?? [])
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
strict: config?.strict ?? false,
|
|
21
|
+
grant(...categories: string[]): void {
|
|
22
|
+
for (const c of categories) granted.add(c)
|
|
23
|
+
},
|
|
24
|
+
revoke(...categories: string[]): void {
|
|
25
|
+
for (const c of categories) granted.delete(c)
|
|
26
|
+
},
|
|
27
|
+
granted(): string[] {
|
|
28
|
+
return [...granted]
|
|
29
|
+
},
|
|
30
|
+
isGranted(category: string): boolean {
|
|
31
|
+
return granted.has(category)
|
|
32
|
+
},
|
|
33
|
+
allows(required?: string[]): boolean {
|
|
34
|
+
if (!required || required.length === 0) return true
|
|
35
|
+
return required.every((c) => granted.has(c))
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Consent = ReturnType<typeof createConsent>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AnalyticsEvent, AnalyticsSink } from './types.js'
|
|
2
|
+
|
|
3
|
+
export interface ConsoleSinkOptions {
|
|
4
|
+
/** Injected logger (defaults to globalThis.console). */
|
|
5
|
+
logger?: Pick<Console, 'log' | 'groupCollapsed' | 'groupEnd'>
|
|
6
|
+
/** Consent categories this sink requires. Default: none (always allowed). */
|
|
7
|
+
consentCategories?: string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Built-in `console` sink — the dev preset's default. Prints each canonical
|
|
12
|
+
* envelope so engineers can see exactly what would ship over the wire.
|
|
13
|
+
*/
|
|
14
|
+
export function createConsoleSink(
|
|
15
|
+
options: ConsoleSinkOptions = {},
|
|
16
|
+
): AnalyticsSink {
|
|
17
|
+
const logger =
|
|
18
|
+
options.logger ??
|
|
19
|
+
(globalThis as unknown as { console: Console }).console
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
name: 'console',
|
|
23
|
+
consentCategories: options.consentCategories,
|
|
24
|
+
deliver(batch: AnalyticsEvent[]): void {
|
|
25
|
+
for (const ev of batch) {
|
|
26
|
+
const label = `[analytics] ${ev.type}${ev.event ? ` ${ev.event}` : ''}`
|
|
27
|
+
if (typeof logger.groupCollapsed === 'function') {
|
|
28
|
+
logger.groupCollapsed(label)
|
|
29
|
+
logger.log(ev)
|
|
30
|
+
logger.groupEnd?.()
|
|
31
|
+
} else {
|
|
32
|
+
logger.log(label, ev)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnalyticsEvent,
|
|
3
|
+
AnalyticsSink,
|
|
4
|
+
HttpSinkOptions,
|
|
5
|
+
SinkDeliverContext,
|
|
6
|
+
} from './types.js'
|
|
7
|
+
import { SCHEMA_VERSION } from './types.js'
|
|
8
|
+
import { uuidv4 } from './uuid.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Built-in `http` sink — Segment HTTP Tracking API wire contract.
|
|
12
|
+
*
|
|
13
|
+
* Wire contract (adopt, do not invent — RudderStack/Jitsu/Segment conform):
|
|
14
|
+
*
|
|
15
|
+
* POST {endpoint}/v{schemaVersion}/batch
|
|
16
|
+
* Content-Type: application/json
|
|
17
|
+
* Authorization: Basic base64(writeKey:) (note the trailing colon)
|
|
18
|
+
* Body: { batch: AnalyticsEvent[], sentAt, batchId }
|
|
19
|
+
*
|
|
20
|
+
* Each event carries `messageId` (idempotency — backends MUST dedupe),
|
|
21
|
+
* `anonymousId`, `userId?`, `sessionId`, `type`, `event?`,
|
|
22
|
+
* `properties`/`traits`, `context`, `timestamp`, `schemaVersion`.
|
|
23
|
+
*
|
|
24
|
+
* Response handling (accept-and-queue semantics):
|
|
25
|
+
* 200 accepted (the backend has only queued it, not processed it)
|
|
26
|
+
* 400 malformed → DROP, never retry
|
|
27
|
+
* 401 bad write key → DROP, never retry
|
|
28
|
+
* 413 payload too big → DROP (we also pre-split under the size caps)
|
|
29
|
+
* 429 / 5xx transient → exponential backoff retry
|
|
30
|
+
*
|
|
31
|
+
* Clock skew: the backend corrects with
|
|
32
|
+
* corrected = timestamp + (receivedAt − sentAt)
|
|
33
|
+
* so we always stamp an honest client `sentAt`.
|
|
34
|
+
*
|
|
35
|
+
* sendBeacon caveat: the unload path (`pagehide`/`visibilitychange`) uses
|
|
36
|
+
* `navigator.sendBeacon`, which cannot set an `Authorization` header. On that
|
|
37
|
+
* path we fall back to `?writeKey=` in the query string with a
|
|
38
|
+
* `text/plain` body (the wire contract requires the backend to accept this).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const NO_RETRY = new Set([400, 401, 413])
|
|
42
|
+
|
|
43
|
+
function base64(input: string): string {
|
|
44
|
+
const g = globalThis as unknown as {
|
|
45
|
+
btoa?: (s: string) => string
|
|
46
|
+
Buffer?: { from(s: string, enc: string): { toString(enc: string): string } }
|
|
47
|
+
}
|
|
48
|
+
if (typeof g.btoa === 'function') {
|
|
49
|
+
return g.btoa(input)
|
|
50
|
+
}
|
|
51
|
+
if (g.Buffer) {
|
|
52
|
+
return g.Buffer.from(input, 'utf-8').toString('base64')
|
|
53
|
+
}
|
|
54
|
+
throw new Error('No base64 implementation available (btoa/Buffer)')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function byteLength(s: string): number {
|
|
58
|
+
const g = globalThis as unknown as {
|
|
59
|
+
TextEncoder?: new () => { encode(s: string): { length: number } }
|
|
60
|
+
}
|
|
61
|
+
if (g.TextEncoder) return new g.TextEncoder().encode(s).length
|
|
62
|
+
// Conservative fallback (UTF-8 worst case is 4 bytes/char; use 3 as typical).
|
|
63
|
+
return unescape(encodeURIComponent(s)).length
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sleep = (ms: number) =>
|
|
67
|
+
new Promise<void>((r) => setTimeout(r, ms))
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Split a batch so neither the per-event nor per-batch byte caps are
|
|
71
|
+
* exceeded. Over-sized single events are dropped (they can never be sent).
|
|
72
|
+
*/
|
|
73
|
+
function splitBatch(
|
|
74
|
+
batch: AnalyticsEvent[],
|
|
75
|
+
maxBatchBytes: number,
|
|
76
|
+
maxEventBytes: number,
|
|
77
|
+
): { batches: AnalyticsEvent[][]; dropped: AnalyticsEvent[] } {
|
|
78
|
+
const batches: AnalyticsEvent[][] = []
|
|
79
|
+
const dropped: AnalyticsEvent[] = []
|
|
80
|
+
let current: AnalyticsEvent[] = []
|
|
81
|
+
let currentBytes = 2 // "[]"
|
|
82
|
+
|
|
83
|
+
for (const ev of batch) {
|
|
84
|
+
const evBytes = byteLength(JSON.stringify(ev))
|
|
85
|
+
if (evBytes > maxEventBytes) {
|
|
86
|
+
dropped.push(ev)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (current.length && currentBytes + evBytes + 1 > maxBatchBytes) {
|
|
90
|
+
batches.push(current)
|
|
91
|
+
current = []
|
|
92
|
+
currentBytes = 2
|
|
93
|
+
}
|
|
94
|
+
current.push(ev)
|
|
95
|
+
currentBytes += evBytes + 1
|
|
96
|
+
}
|
|
97
|
+
if (current.length) batches.push(current)
|
|
98
|
+
return { batches, dropped }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Create the built-in Segment-spec HTTP sink. */
|
|
102
|
+
export function createHttpSink(options: HttpSinkOptions): AnalyticsSink {
|
|
103
|
+
const {
|
|
104
|
+
endpoint,
|
|
105
|
+
writeKey,
|
|
106
|
+
maxRetries = 3,
|
|
107
|
+
backoffBaseMs = 500,
|
|
108
|
+
consentCategories = ['analytics'],
|
|
109
|
+
maxBatchBytes = 500_000,
|
|
110
|
+
maxEventBytes = 32_000,
|
|
111
|
+
} = options
|
|
112
|
+
|
|
113
|
+
const base = endpoint.replace(/\/+$/, '')
|
|
114
|
+
const url = `${base}/v${SCHEMA_VERSION}/batch`
|
|
115
|
+
const authHeader = `Basic ${base64(`${writeKey}:`)}`
|
|
116
|
+
|
|
117
|
+
const resolveFetch = (): typeof fetch => {
|
|
118
|
+
if (options.fetchImpl) return options.fetchImpl
|
|
119
|
+
const f = (globalThis as unknown as { fetch?: typeof fetch }).fetch
|
|
120
|
+
if (!f) throw new Error('No fetch implementation available')
|
|
121
|
+
return f
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const resolveBeacon = ():
|
|
125
|
+
| ((u: string, body: string) => boolean)
|
|
126
|
+
| undefined => {
|
|
127
|
+
if (options.beaconImpl) return options.beaconImpl
|
|
128
|
+
const nav = (globalThis as unknown as {
|
|
129
|
+
navigator?: { sendBeacon?: (u: string, data: BodyInit) => boolean }
|
|
130
|
+
}).navigator
|
|
131
|
+
if (nav && typeof nav.sendBeacon === 'function') {
|
|
132
|
+
return (u, body) => nav.sendBeacon!(u, body)
|
|
133
|
+
}
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function envelope(batch: AnalyticsEvent[]) {
|
|
138
|
+
return {
|
|
139
|
+
batch,
|
|
140
|
+
sentAt: new Date().toISOString(),
|
|
141
|
+
batchId: uuidv4(),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Beacon path: writeKey via query string, text/plain body. */
|
|
146
|
+
function sendViaBeacon(batch: AnalyticsEvent[]): boolean {
|
|
147
|
+
const beacon = resolveBeacon()
|
|
148
|
+
if (!beacon) return false
|
|
149
|
+
const beaconUrl = `${url}?writeKey=${encodeURIComponent(writeKey)}`
|
|
150
|
+
return beacon(beaconUrl, JSON.stringify(envelope(batch)))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Standard path: fetch + Authorization header, with backoff retries. */
|
|
154
|
+
async function sendViaFetch(batch: AnalyticsEvent[]): Promise<void> {
|
|
155
|
+
const doFetch = resolveFetch()
|
|
156
|
+
const body = JSON.stringify(envelope(batch))
|
|
157
|
+
|
|
158
|
+
for (let attempt = 0; ; attempt++) {
|
|
159
|
+
let status: number
|
|
160
|
+
try {
|
|
161
|
+
const res = await doFetch(url, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: {
|
|
164
|
+
'Content-Type': 'application/json',
|
|
165
|
+
Authorization: authHeader,
|
|
166
|
+
},
|
|
167
|
+
body,
|
|
168
|
+
keepalive: true,
|
|
169
|
+
})
|
|
170
|
+
status = res.status
|
|
171
|
+
} catch {
|
|
172
|
+
// Network error — treat as transient.
|
|
173
|
+
status = 0
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (status >= 200 && status < 300) return // accepted-and-queued
|
|
177
|
+
if (NO_RETRY.has(status)) return // 400/401/413 — drop, never retry
|
|
178
|
+
|
|
179
|
+
// 429 / 5xx / network (0) → backoff retry.
|
|
180
|
+
if (attempt >= maxRetries) return
|
|
181
|
+
const delay = backoffBaseMs * 2 ** attempt
|
|
182
|
+
await sleep(delay)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
name: 'http',
|
|
188
|
+
consentCategories,
|
|
189
|
+
|
|
190
|
+
async deliver(
|
|
191
|
+
batch: AnalyticsEvent[],
|
|
192
|
+
ctx: SinkDeliverContext,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
if (batch.length === 0) return
|
|
195
|
+
const { batches, dropped } = splitBatch(
|
|
196
|
+
batch,
|
|
197
|
+
maxBatchBytes,
|
|
198
|
+
maxEventBytes,
|
|
199
|
+
)
|
|
200
|
+
void dropped // oversized events are unsendable by contract — silently dropped
|
|
201
|
+
|
|
202
|
+
for (const part of batches) {
|
|
203
|
+
if (ctx.unload) {
|
|
204
|
+
// Unload path: try beacon first; fall back to keepalive fetch.
|
|
205
|
+
if (sendViaBeacon(part)) continue
|
|
206
|
+
void sendViaFetch(part)
|
|
207
|
+
} else {
|
|
208
|
+
await sendViaFetch(part)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|