@hanzo/event 0.3.1 → 0.3.3
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/README.md +81 -17
- package/dist/core-B1XEdWLd.d.cts +296 -0
- package/dist/core-B1XEdWLd.d.ts +296 -0
- package/dist/index.cjs +590 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +177 -6
- package/dist/index.d.ts +177 -6
- package/dist/index.mjs +581 -64
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +413 -25
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.mjs +413 -25
- package/dist/react.mjs.map +1 -1
- package/package.json +2 -2
- package/src/core.test.ts +258 -5
- package/src/core.ts +237 -45
- package/src/events.ts +30 -1
- package/src/funnels.test.ts +92 -0
- package/src/funnels.ts +154 -0
- package/src/goals.ts +25 -11
- package/src/index.ts +10 -0
- package/src/scrub.test.ts +96 -0
- package/src/scrub.ts +117 -0
- package/src/sentry.test.ts +312 -0
- package/src/sentry.ts +309 -0
- package/src/types.ts +117 -15
- package/src/version.ts +4 -0
- package/dist/core-CrbiAQhN.d.cts +0 -186
- package/dist/core-CrbiAQhN.d.ts +0 -186
package/src/core.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
// The framework-agnostic event client.
|
|
2
|
-
//
|
|
1
|
+
// The framework-agnostic event client. ONE API surface over TWO orthogonal
|
|
2
|
+
// planes, sharing one session and one identity:
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// 1. EVENT STREAM — buffered pageview/event/identify/group, flushed as ONE
|
|
5
|
+
// batch to the Hanzo Cloud front door:
|
|
6
|
+
// POST {host}/v1/event body: { batch: [Event, …] } -> { accepted, dropped }
|
|
7
|
+
// Cloud resolves the tenant server-side (validated session, or the signed
|
|
8
|
+
// publishable key) and stamps it; the client NEVER sends the org.
|
|
5
9
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
10
|
+
// 2. ERROR PLANE — every captured exception is ALSO framed as a real Sentry
|
|
11
|
+
// envelope and POSTed to the error host named by the DSN:
|
|
12
|
+
// POST {dsn.origin}/v1/sentry/{projectId}/envelope/?sentry_key=…
|
|
13
|
+
// This is what reaches sentry.hanzo.ai (issues, grouping, stack frames).
|
|
14
|
+
//
|
|
15
|
+
// These are NOT the same pipe and one does NOT feed the other. The event stream
|
|
16
|
+
// stores a `type:'error'` row in the cloud event warehouse (readable via
|
|
17
|
+
// GET /v1/errors) — that is product signal, not error tracking. There is no
|
|
18
|
+
// server-side fan-out from /v1/event into Sentry; without the envelope below,
|
|
19
|
+
// nothing ever reaches sentry.hanzo.ai. An earlier revision of this file claimed
|
|
20
|
+
// the one door was "lensed server-side into … error tracking (sentry)". It was
|
|
21
|
+
// wrong, and it silently cost the fleet all of its error telemetry.
|
|
22
|
+
//
|
|
23
|
+
// The error plane is inert (fail-safe) when no DSN is configured: nothing is
|
|
24
|
+
// sent, nothing throws, and the event stream is unaffected.
|
|
11
25
|
//
|
|
12
26
|
// Auth is orthogonal — the SAME body to the SAME door, differing only in how the
|
|
13
27
|
// caller proves its tenant:
|
|
@@ -19,10 +33,10 @@
|
|
|
19
33
|
// safe to ship in a bundle; the door HMAC-verifies it to an org server-side.
|
|
20
34
|
//
|
|
21
35
|
// The wire is the canonical `Event` (== the cloud CaptureEvent): its `type` field
|
|
22
|
-
// is what Cloud folds to event_type='error',
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
36
|
+
// is what Cloud folds to event_type='error', which is how the event WAREHOUSE
|
|
37
|
+
// classifies the row (GET /v1/errors). That is the extent of it — the fold does
|
|
38
|
+
// not forward anything to Sentry. The error dashboard is fed only by the envelope
|
|
39
|
+
// in plane 2 above, and only when a DSN is set.
|
|
26
40
|
|
|
27
41
|
import {
|
|
28
42
|
parseAttribution,
|
|
@@ -30,6 +44,13 @@ import {
|
|
|
30
44
|
deriveChannel,
|
|
31
45
|
} from './attribution'
|
|
32
46
|
import { PAGEVIEW } from './events'
|
|
47
|
+
import {
|
|
48
|
+
buildEnvelope,
|
|
49
|
+
buildSentryEvent,
|
|
50
|
+
normalizeError as normalizeThrowable,
|
|
51
|
+
parseDsn,
|
|
52
|
+
type ErrorIdentity,
|
|
53
|
+
} from './sentry'
|
|
33
54
|
import {
|
|
34
55
|
anonId,
|
|
35
56
|
sessionId,
|
|
@@ -41,17 +62,46 @@ import {
|
|
|
41
62
|
import type {
|
|
42
63
|
AnalyticsConfig,
|
|
43
64
|
Attribution,
|
|
65
|
+
CaptureErrorOptions,
|
|
44
66
|
Cohort,
|
|
67
|
+
Dsn,
|
|
45
68
|
EventKind,
|
|
46
69
|
Exception,
|
|
47
70
|
Transport,
|
|
48
71
|
WireEvent,
|
|
49
72
|
} from './types'
|
|
73
|
+
import { VERSION } from './version'
|
|
50
74
|
|
|
51
|
-
export
|
|
75
|
+
export { VERSION }
|
|
52
76
|
|
|
53
77
|
const EVENT_PATH = '/v1/event' // the ONE canonical ingestion front door
|
|
54
78
|
const DEFAULT_HOST = 'https://api.hanzo.ai' // the one edge; cookie apps pass host:''
|
|
79
|
+
const ENVELOPE_CONTENT_TYPE = 'application/x-sentry-envelope'
|
|
80
|
+
|
|
81
|
+
/** readEnvDsn resolves a DSN from the public env when config omits one, so an app
|
|
82
|
+
* gets the error plane by setting ONE build-time variable and nothing else.
|
|
83
|
+
* Next/Vite inline these at build; the access is guarded so it is safe in a bare
|
|
84
|
+
* browser and during SSR/prerender where `process` may not exist. */
|
|
85
|
+
function readEnvDsn(): string | undefined {
|
|
86
|
+
try {
|
|
87
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
88
|
+
return process.env.NEXT_PUBLIC_HANZO_EVENT_DSN || process.env.HANZO_EVENT_DSN || undefined
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
/* no process — browser without inlined env */
|
|
92
|
+
}
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** readEnv reads an inlined build-time variable, guarded like readEnvDsn. */
|
|
97
|
+
function readEnv(name: string): string | undefined {
|
|
98
|
+
try {
|
|
99
|
+
if (typeof process !== 'undefined' && process.env) return process.env[name] || undefined
|
|
100
|
+
} catch {
|
|
101
|
+
/* no process */
|
|
102
|
+
}
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
55
105
|
|
|
56
106
|
/** appendQuery adds a single query param to a URL string — used to carry a
|
|
57
107
|
* publishable key on a headerless sendBeacon (?ingest_key=…). */
|
|
@@ -66,37 +116,75 @@ function uid(): string {
|
|
|
66
116
|
}
|
|
67
117
|
|
|
68
118
|
/** Normalize anything thrown (Error | string | unknown) into an Exception. */
|
|
119
|
+
/** normalizeError adapts the shared, hostile-input-safe normalizer (sentry.ts) to
|
|
120
|
+
* the event stream's Exception shape. ONE normalizer serves both planes: a thrown
|
|
121
|
+
* object may define `name`/`message`/`stack` as throwing getters, and when each
|
|
122
|
+
* plane rolled its own reader the stream still lost the report that the error
|
|
123
|
+
* plane had already survived. */
|
|
69
124
|
function normalizeError(err: unknown): Exception {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
125
|
+
const n = normalizeThrowable(err)
|
|
126
|
+
return { type: n.name, message: n.message, stack: n.stack }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const isBrowser = () => typeof window !== 'undefined'
|
|
130
|
+
|
|
131
|
+
/** serializeBatch stringifies a batch, salvaging what it can. `properties` is
|
|
132
|
+
* arbitrary caller data — a DOM node, a React synthetic event, an axios error are
|
|
133
|
+
* all circular, and a getter on one can throw — so a whole batch must never be
|
|
134
|
+
* lost to a single poisoned event. Falls back to per-event serialization, then to
|
|
135
|
+
* keeping the event and dropping its properties. Returns null only when nothing
|
|
136
|
+
* at all survives. */
|
|
137
|
+
function serializeBatch(batch: WireEvent[]): string | null {
|
|
74
138
|
try {
|
|
75
|
-
return
|
|
139
|
+
return JSON.stringify({ batch })
|
|
76
140
|
} catch {
|
|
77
|
-
|
|
141
|
+
/* one bad event — salvage the rest below */
|
|
142
|
+
}
|
|
143
|
+
const parts: string[] = []
|
|
144
|
+
for (const e of batch) {
|
|
145
|
+
try {
|
|
146
|
+
parts.push(JSON.stringify(e))
|
|
147
|
+
} catch {
|
|
148
|
+
try {
|
|
149
|
+
// Keep the event (identity, session, type all matter); drop the payload
|
|
150
|
+
// that could not be serialized, and say so rather than lying by omission.
|
|
151
|
+
parts.push(JSON.stringify({ ...e, properties: { $unserializable: true } }))
|
|
152
|
+
} catch {
|
|
153
|
+
/* unsalvageable — drop this ONE event, never the batch */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
78
156
|
}
|
|
157
|
+
return parts.length > 0 ? '{"batch":[' + parts.join(',') + ']}' : null
|
|
79
158
|
}
|
|
80
159
|
|
|
81
|
-
const isBrowser = () => typeof window !== 'undefined'
|
|
82
|
-
|
|
83
160
|
/** DefaultTransport: fetch(keepalive) for authenticated/normal sends;
|
|
84
161
|
* navigator.sendBeacon for headerless page-unload beacons. A bearer (a JWT or a
|
|
85
162
|
* publishable pk_ key) rides Authorization on fetch; on a beacon — which cannot
|
|
86
163
|
* set headers — a publishable key rides the ?ingest_key query instead. */
|
|
87
164
|
class DefaultTransport implements Transport {
|
|
88
|
-
send(
|
|
165
|
+
send(
|
|
166
|
+
url: string,
|
|
167
|
+
body: string,
|
|
168
|
+
opts: {
|
|
169
|
+
beacon: boolean
|
|
170
|
+
token?: string
|
|
171
|
+
ingestKey?: string
|
|
172
|
+
contentType?: string
|
|
173
|
+
debug?: boolean
|
|
174
|
+
},
|
|
175
|
+
): void {
|
|
176
|
+
const contentType = opts.contentType ?? 'application/json'
|
|
89
177
|
if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === 'function') {
|
|
90
178
|
const beaconUrl = opts.ingestKey ? appendQuery(url, 'ingest_key', opts.ingestKey) : url
|
|
91
179
|
try {
|
|
92
|
-
navigator.sendBeacon(beaconUrl, new Blob([body], { type:
|
|
180
|
+
navigator.sendBeacon(beaconUrl, new Blob([body], { type: contentType }))
|
|
93
181
|
return
|
|
94
182
|
} catch {
|
|
95
183
|
/* fall through to fetch */
|
|
96
184
|
}
|
|
97
185
|
}
|
|
98
186
|
if (typeof fetch !== 'function') return
|
|
99
|
-
const headers: Record<string, string> = { 'Content-Type':
|
|
187
|
+
const headers: Record<string, string> = { 'Content-Type': contentType }
|
|
100
188
|
const bearer = opts.ingestKey ?? opts.token
|
|
101
189
|
if (bearer) headers.Authorization = `Bearer ${bearer}`
|
|
102
190
|
void fetch(url, {
|
|
@@ -105,9 +193,19 @@ class DefaultTransport implements Transport {
|
|
|
105
193
|
body,
|
|
106
194
|
keepalive: true,
|
|
107
195
|
credentials: 'include',
|
|
108
|
-
}).catch(() => {
|
|
109
|
-
/* telemetry loss is acceptable; never throw into the app */
|
|
110
196
|
})
|
|
197
|
+
.then((res) => {
|
|
198
|
+
// Telemetry loss never throws into the app — but silence is how this
|
|
199
|
+
// client lost the fleet's errors in the first place. Under `debug`, say
|
|
200
|
+
// so. A rejected ingest (the CORS allowlist 403s non-production origins,
|
|
201
|
+
// so local dev NEVER reports) is otherwise indistinguishable from success.
|
|
202
|
+
if (!res.ok && opts.debug) {
|
|
203
|
+
console.warn('[event] ingest rejected', res.status, url.split('?')[0])
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
.catch((e: unknown) => {
|
|
207
|
+
if (opts.debug) console.warn('[event] ingest failed', url.split('?')[0], e)
|
|
208
|
+
})
|
|
111
209
|
}
|
|
112
210
|
}
|
|
113
211
|
|
|
@@ -123,6 +221,10 @@ export class Analytics {
|
|
|
123
221
|
private attribution: Attribution = { utm: {} }
|
|
124
222
|
private cohort: Cohort = {}
|
|
125
223
|
private started = false
|
|
224
|
+
/** Parsed error-plane DSN, or null when the plane is inert. */
|
|
225
|
+
private dsn: Dsn | null
|
|
226
|
+
/** Guards against an error thrown *inside* the error path re-entering it. */
|
|
227
|
+
private reentrant = false
|
|
126
228
|
|
|
127
229
|
constructor(config: AnalyticsConfig) {
|
|
128
230
|
this.cfg = {
|
|
@@ -134,6 +236,23 @@ export class Analytics {
|
|
|
134
236
|
...config,
|
|
135
237
|
}
|
|
136
238
|
this.transport = config.transport ?? new DefaultTransport()
|
|
239
|
+
// Error plane: explicit DSN wins, else the inlined build-time env. Malformed
|
|
240
|
+
// or absent => null => inert, never throwing into the host app.
|
|
241
|
+
this.dsn = parseDsn(config.dsn ?? readEnvDsn())
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** errorPlaneEnabled reports whether captured exceptions can actually reach the
|
|
245
|
+
* error host. False means a DSN was never configured — the documented
|
|
246
|
+
* fail-safe. Exposed so an app (or a test) can assert its wiring instead of
|
|
247
|
+
* discovering months later that nothing was ever reported. */
|
|
248
|
+
get errorPlaneEnabled(): boolean {
|
|
249
|
+
return this.dsn !== null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** errorIngestUrl is the fully-derived envelope endpoint, or undefined when the
|
|
253
|
+
* plane is inert. Diagnostics only. */
|
|
254
|
+
get errorIngestUrl(): string | undefined {
|
|
255
|
+
return this.dsn?.ingestUrl
|
|
137
256
|
}
|
|
138
257
|
|
|
139
258
|
/** init is idempotent and browser-only for its side effects: capture first-touch
|
|
@@ -161,8 +280,9 @@ export class Analytics {
|
|
|
161
280
|
window.addEventListener('pagehide', () => this.flush(true))
|
|
162
281
|
|
|
163
282
|
// Auto error capture — the drop-in @sentry replacement. Unhandled errors and
|
|
164
|
-
// rejected promises
|
|
165
|
-
//
|
|
283
|
+
// rejected promises are reported on BOTH planes: a Sentry envelope to the DSN
|
|
284
|
+
// host (what reaches the error dashboard — requires a DSN) and a type:'error'
|
|
285
|
+
// event on the stream (product signal in the warehouse).
|
|
166
286
|
if (this.cfg.captureErrors) {
|
|
167
287
|
window.addEventListener('error', (e: ErrorEvent) => {
|
|
168
288
|
this.captureError(e.error ?? e.message, { handled: false })
|
|
@@ -205,21 +325,49 @@ export class Analytics {
|
|
|
205
325
|
/** track is an alias of capture (Segment familiarity). */
|
|
206
326
|
track = this.capture.bind(this)
|
|
207
327
|
|
|
208
|
-
/** captureError
|
|
209
|
-
* error
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
328
|
+
/** captureError reports a caught error, an unhandled rejection, a React render
|
|
329
|
+
* error, or a manual report to BOTH planes, from one call:
|
|
330
|
+
*
|
|
331
|
+
* - the ERROR PLANE — a real Sentry envelope to the DSN host. This is the one
|
|
332
|
+
* that produces an issue in sentry.hanzo.ai (grouping, stack frames, AST).
|
|
333
|
+
* Inert when no DSN is configured.
|
|
334
|
+
* - the EVENT STREAM — a `type:'error'` row in the cloud event warehouse, so
|
|
335
|
+
* an error stays correlated with the session's pageviews for product
|
|
336
|
+
* analysis (readable via GET /v1/errors).
|
|
337
|
+
*
|
|
338
|
+
* Both carry the SAME session and subject id, so an error and the pageview
|
|
339
|
+
* before it join up. Never throws back into the app; errors are higher-signal
|
|
340
|
+
* than pageviews, so both planes flush promptly (a crash may unload the page
|
|
341
|
+
* moments later). */
|
|
342
|
+
captureError(err: unknown, context?: CaptureErrorOptions): void {
|
|
343
|
+
// A failure inside the error path must not recurse through the global handlers.
|
|
344
|
+
if (this.reentrant) return
|
|
345
|
+
this.reentrant = true
|
|
346
|
+
try {
|
|
347
|
+
// ERROR PLANE FIRST, in its own try. The planes are independent, so neither
|
|
348
|
+
// may be able to starve the other: `properties` is arbitrary caller data
|
|
349
|
+
// (a DOM node, a React synthetic event, an axios error — all circular and
|
|
350
|
+
// all common), and serializing it on the event stream can throw. When the
|
|
351
|
+
// stream ran first, that throw escaped to the outer catch and the crash
|
|
352
|
+
// report was never sent — silently losing exactly the signal this client
|
|
353
|
+
// exists to deliver. Order and isolation are the fix.
|
|
354
|
+
try {
|
|
355
|
+
this.sendError(err, context)
|
|
356
|
+
} catch {
|
|
357
|
+
/* the error plane must never take the event stream down with it */
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
const ex = normalizeError(err)
|
|
362
|
+
ex.handled = context?.handled ?? true
|
|
363
|
+
this.enqueue('error', ex.message, { error: ex, properties: context?.properties })
|
|
364
|
+
this.flush()
|
|
365
|
+
} catch {
|
|
366
|
+
/* nor the reverse */
|
|
367
|
+
}
|
|
368
|
+
} finally {
|
|
369
|
+
this.reentrant = false
|
|
370
|
+
}
|
|
223
371
|
}
|
|
224
372
|
|
|
225
373
|
/** captureException — @sentry-familiar alias of captureError. */
|
|
@@ -254,13 +402,57 @@ export class Analytics {
|
|
|
254
402
|
// Only a headerful bearer JWT blocks the beacon: sendBeacon cannot set an
|
|
255
403
|
// Authorization header. A pk_ rides ?ingest_key; a cookie rides credentials.
|
|
256
404
|
const useBeacon = beacon && !token
|
|
257
|
-
const body =
|
|
405
|
+
const body = serializeBatch(batch)
|
|
406
|
+
if (body === null) {
|
|
407
|
+
if (this.cfg.debug) console.debug('[event] flush → dropped, batch unserializable')
|
|
408
|
+
return
|
|
409
|
+
}
|
|
258
410
|
if (this.cfg.debug) console.debug('[event] flush →', EVENT_PATH, batch.length)
|
|
259
|
-
this.transport.send(this.cfg.host + EVENT_PATH, body, {
|
|
411
|
+
this.transport.send(this.cfg.host + EVENT_PATH, body, {
|
|
412
|
+
beacon: useBeacon,
|
|
413
|
+
token,
|
|
414
|
+
ingestKey: key,
|
|
415
|
+
debug: this.cfg.debug,
|
|
416
|
+
})
|
|
260
417
|
}
|
|
261
418
|
|
|
262
419
|
// ── internals ────────────────────────────────────────────────────────────
|
|
263
420
|
|
|
421
|
+
/** sendError frames one exception as a Sentry envelope and posts it to the DSN's
|
|
422
|
+
* ingest URL. The DSN's own key rides ?sentry_key= (the credential channel the
|
|
423
|
+
* server trusts, and the only one a headerless beacon can carry), so NO bearer
|
|
424
|
+
* or publishable key is attached here — the two planes authenticate
|
|
425
|
+
* independently. Errors are sent one envelope per event, immediately: batching
|
|
426
|
+
* a crash report is how you lose it. */
|
|
427
|
+
private sendError(err: unknown, options?: CaptureErrorOptions): void {
|
|
428
|
+
if (!this.cfg.enabled || !this.dsn) return
|
|
429
|
+
const event = buildSentryEvent({
|
|
430
|
+
error: err,
|
|
431
|
+
options,
|
|
432
|
+
identity: this.errorIdentity(),
|
|
433
|
+
capturePII: this.cfg.capturePII ?? false,
|
|
434
|
+
})
|
|
435
|
+
const body = buildEnvelope(event, this.dsn)
|
|
436
|
+
if (this.cfg.debug) console.debug('[event] error →', this.dsn.ingestUrl, event.event_id)
|
|
437
|
+
this.transport.send(this.dsn.ingestUrl, body, {
|
|
438
|
+
beacon: false,
|
|
439
|
+
contentType: ENVELOPE_CONTENT_TYPE,
|
|
440
|
+
debug: this.cfg.debug,
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** errorIdentity is the SAME identity the event stream stamps — the OIDC subject
|
|
445
|
+
* once identify() has run, else the anon id. Never email/PII. */
|
|
446
|
+
private errorIdentity(): ErrorIdentity {
|
|
447
|
+
return {
|
|
448
|
+
userId: this.personId ?? anonId(),
|
|
449
|
+
sessionId: sessionId(),
|
|
450
|
+
product: this.cfg.product,
|
|
451
|
+
release: this.cfg.release ?? readEnv('NEXT_PUBLIC_HANZO_RELEASE'),
|
|
452
|
+
environment: this.cfg.environment ?? readEnv('NODE_ENV'),
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
264
456
|
private enqueue(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): void {
|
|
265
457
|
if (!this.cfg.enabled) return
|
|
266
458
|
if (!this.started) this.init()
|
package/src/events.ts
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
// names so funnels, goals, and cohorts line up across console/chat/app/site/admin.
|
|
3
3
|
// Pageviews use the reserved "$pageview" name (emitted by analytics.pageview()),
|
|
4
4
|
// matching the server read lens.
|
|
5
|
+
//
|
|
6
|
+
// Naming is a closed convention, not a style preference (see TAXONOMY.md):
|
|
7
|
+
// • snake_case, `<object>_<verb-in-past-tense>` — signup_completed, plan_clicked.
|
|
8
|
+
// • Names are CONSTANTS, never interpolated. A dimension (framework, model,
|
|
9
|
+
// plan, source) is a PROPERTY, never part of the name. `deploy_succeeded`
|
|
10
|
+
// with {framework:'static'} — never `deploy_static_succeeded`.
|
|
11
|
+
// • One name per user-visible moment, shared by every surface. The surface is
|
|
12
|
+
// already on the wire as `product`, so a name is never product-prefixed.
|
|
5
13
|
|
|
6
14
|
export const EVENTS = {
|
|
7
15
|
// Signup funnel: view -> submit -> verify -> completed -> first action.
|
|
@@ -9,6 +17,12 @@ export const EVENTS = {
|
|
|
9
17
|
SIGNUP_SUBMITTED: 'signup_submitted',
|
|
10
18
|
SIGNUP_VERIFIED: 'signup_verified',
|
|
11
19
|
SIGNUP_COMPLETED: 'signup_completed',
|
|
20
|
+
/** A RETURNING user authenticated — the non-signup half of the IAM callback.
|
|
21
|
+
* Keeping it distinct is what stops returning logins from inflating signups. */
|
|
22
|
+
LOGIN_COMPLETED: 'login_completed',
|
|
23
|
+
/** Activation: the first moment of real value. ONE event for every product —
|
|
24
|
+
* the product-specific moment is the `action` property (api_call, app_live,
|
|
25
|
+
* chat_reply), never a new event name. */
|
|
12
26
|
FIRST_ACTION: 'first_action',
|
|
13
27
|
|
|
14
28
|
// Waitlist + referral.
|
|
@@ -27,13 +41,28 @@ export const EVENTS = {
|
|
|
27
41
|
FEATURE_USED: 'feature_used',
|
|
28
42
|
API_KEY_CREATED: 'api_key_created',
|
|
29
43
|
APP_CREATED: 'app_created',
|
|
30
|
-
DEPLOY_STARTED: 'deploy_started',
|
|
31
44
|
PROJECT_CREATED: 'project_created',
|
|
32
45
|
AGENT_CREATED: 'agent_created',
|
|
33
46
|
CHAT_STARTED: 'chat_started',
|
|
34
47
|
CHAT_MESSAGE_SENT: 'chat_message_sent',
|
|
48
|
+
/** The user switched model/endpoint — the single strongest quality signal a
|
|
49
|
+
* chat surface emits (a switch usually follows a bad answer). */
|
|
50
|
+
MODEL_SWITCHED: 'model_switched',
|
|
35
51
|
TASK_STARTED: 'task_started',
|
|
36
52
|
TASK_COMPLETED: 'task_completed',
|
|
53
|
+
|
|
54
|
+
// Build → ship. `build_*` is a MODEL producing an artifact; `deploy_*` is that
|
|
55
|
+
// artifact going live. Intent (build_started) is never the same event as the
|
|
56
|
+
// artifact existing (app_created) — conflating them makes the funnel lie.
|
|
57
|
+
BUILD_STARTED: 'build_started',
|
|
58
|
+
/** A model finished producing an artifact (an app build, a chat reply, an agent
|
|
59
|
+
* run). Carries `durationMs` — the outcome event owns its own duration, so no
|
|
60
|
+
* paired start event is needed. */
|
|
61
|
+
GENERATION_COMPLETED: 'generation_completed',
|
|
62
|
+
GENERATION_FAILED: 'generation_failed',
|
|
63
|
+
DEPLOY_STARTED: 'deploy_started',
|
|
64
|
+
DEPLOY_SUCCEEDED: 'deploy_succeeded',
|
|
65
|
+
DEPLOY_FAILED: 'deploy_failed',
|
|
37
66
|
} as const
|
|
38
67
|
|
|
39
68
|
export type EventName = (typeof EVENTS)[keyof typeof EVENTS]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { EVENTS, PAGEVIEW } from './events'
|
|
3
|
+
import { FUNNELS, PRODUCTS, eventsOf, type FunnelId } from './funnels'
|
|
4
|
+
import { GOALS } from './goals'
|
|
5
|
+
|
|
6
|
+
const NAMES = new Set<string>([...Object.values(EVENTS), PAGEVIEW])
|
|
7
|
+
const ids = Object.keys(FUNNELS) as FunnelId[]
|
|
8
|
+
|
|
9
|
+
describe('event vocabulary', () => {
|
|
10
|
+
it('is snake_case with no product prefix or interpolation', () => {
|
|
11
|
+
for (const name of Object.values(EVENTS)) {
|
|
12
|
+
expect(name, name).toMatch(/^[a-z][a-z0-9]*(_[a-z0-9]+)+$/)
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('has no duplicate values (two constants must never share a name)', () => {
|
|
17
|
+
const values = Object.values(EVENTS)
|
|
18
|
+
expect(new Set(values).size).toBe(values.length)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('reserves the $ prefix for the client (only $pageview)', () => {
|
|
22
|
+
expect(Object.values(EVENTS).some((n) => n.startsWith('$'))).toBe(false)
|
|
23
|
+
expect(PAGEVIEW).toBe('$pageview')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('funnels', () => {
|
|
28
|
+
it('only reference events that exist — the anti-drift guard', () => {
|
|
29
|
+
for (const id of ids) {
|
|
30
|
+
for (const step of FUNNELS[id].steps) {
|
|
31
|
+
expect(NAMES.has(step.event), `${id}: unknown event "${step.event}"`).toBe(true)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('name only known products', () => {
|
|
37
|
+
for (const id of ids) {
|
|
38
|
+
for (const p of FUNNELS[id].products) {
|
|
39
|
+
expect(PRODUCTS, `${id}: unknown product "${p}"`).toContain(p)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('are ordered lists of at least two steps', () => {
|
|
45
|
+
for (const id of ids) expect(FUNNELS[id].steps.length, id).toBeGreaterThanOrEqual(2)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('mark a cross-origin funnel as aggregate (no shared anonymousId)', () => {
|
|
49
|
+
// Two surfaces + a logged-out visitor = two anonymousIds. Anything spanning
|
|
50
|
+
// origins must say so, or the read lens silently reports a false conversion.
|
|
51
|
+
for (const id of ids) {
|
|
52
|
+
if (FUNNELS[id].products.length > 1 && FUNNELS[id].join === 'person') {
|
|
53
|
+
// person-joined multi-product funnels are only legitimate post-login
|
|
54
|
+
// (site+cloud share the OIDC subject) — flag anything anonymous.
|
|
55
|
+
expect(FUNNELS[id].steps[0].event, id).not.toBe(PAGEVIEW)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
expect(FUNNELS.siteToChat.join).toBe('aggregate')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('the three product journeys are covered', () => {
|
|
62
|
+
expect(FUNNELS.signup.products).toContain('site')
|
|
63
|
+
expect(FUNNELS.appShip.products).toContain('app')
|
|
64
|
+
expect(FUNNELS.chatEngage.products).toContain('chat')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('appShip ends at a live URL, not at a click', () => {
|
|
68
|
+
const last = FUNNELS.appShip.steps[FUNNELS.appShip.steps.length - 1]
|
|
69
|
+
expect(last.event).toBe(EVENTS.DEPLOY_SUCCEEDED)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('goals', () => {
|
|
74
|
+
it('derive their funnel from the registry — one definition, never restated', () => {
|
|
75
|
+
for (const goal of Object.values(GOALS)) {
|
|
76
|
+
if (!goal.funnelId) continue
|
|
77
|
+
expect(goal.funnel).toEqual(eventsOf(goal.funnelId))
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('convert on an event the funnel actually contains (or its head)', () => {
|
|
82
|
+
expect(GOALS.signup.funnel).toContain(EVENTS.SIGNUP_COMPLETED)
|
|
83
|
+
expect(GOALS.sale.funnel).toContain(EVENTS.ORDER_COMPLETED)
|
|
84
|
+
expect(GOALS.activation.funnel).toContain(EVENTS.FIRST_ACTION)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('no longer gates signup on an event nothing emits', () => {
|
|
88
|
+
// signup_verified is IAM-internal: no surface emits it, so its presence in
|
|
89
|
+
// the signup funnel pinned the conversion rate at 0.
|
|
90
|
+
expect(GOALS.signup.funnel).not.toContain(EVENTS.SIGNUP_VERIFIED)
|
|
91
|
+
})
|
|
92
|
+
})
|