@hanzo/event 0.3.2 → 0.3.4
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 +20 -0
- package/TAXONOMY.md +257 -0
- package/dist/index.cjs +242 -80
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +147 -5
- package/dist/index.d.ts +147 -5
- package/dist/index.mjs +238 -81
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +43 -15
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +43 -15
- package/dist/react.mjs.map +1 -1
- package/package.json +4 -3
- package/src/core.test.ts +9 -4
- package/src/core.ts +21 -13
- package/src/dsn.test.ts +62 -0
- package/src/dsn.ts +45 -0
- 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 +3 -0
- package/src/scrub.ts +6 -1
- package/src/sentry.test.ts +52 -0
- package/src/sentry.ts +33 -3
- package/src/version.ts +1 -1
package/src/dsn.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The product → Sentry DSN registry.
|
|
3
|
+
*
|
|
4
|
+
* An app declares WHAT it is (`product: 'console'`); this module knows WHERE its
|
|
5
|
+
* errors go. That split is the whole point: no surface has to learn a DSN, carry
|
|
6
|
+
* a build argument, or grow a config file to report errors — declaring the
|
|
7
|
+
* product it already declares is enough.
|
|
8
|
+
*
|
|
9
|
+
* A Sentry DSN is PUBLIC by construction. It ships inside the client bundle and
|
|
10
|
+
* is readable in devtools on any deployed page, and it grants exactly one
|
|
11
|
+
* capability: submitting new events. It cannot read issues, projects, or any
|
|
12
|
+
* other data. So committing it is not leaking a secret — it is recording a public
|
|
13
|
+
* identifier next to the code that needs it. (Contrast the server-side collector
|
|
14
|
+
* DSN in the `team-analytics-sentry` Secret, which is HMAC-derived and revocable
|
|
15
|
+
* precisely because a server-side credential is NOT public.)
|
|
16
|
+
*
|
|
17
|
+
* Why a literal map instead of deriving `hanzo-${product}`: the projects predate
|
|
18
|
+
* this registry and do not derive cleanly — `site` lives in `hanzo-ai`, not
|
|
19
|
+
* `hanzo-site`. An explicit map is honest about that; a derivation rule plus an
|
|
20
|
+
* exception table is the same data with a trap in it.
|
|
21
|
+
*
|
|
22
|
+
* Projects are org-scoped and named `<org>-<app>`. To add one: create the project
|
|
23
|
+
* (POST /v1/sentry/projects with X-Org-Id), then add its `dsn` here keyed by the
|
|
24
|
+
* product name the app passes to `createAnalytics`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** PRODUCT_DSN maps a `product` to the DSN its errors are submitted to. */
|
|
28
|
+
export const PRODUCT_DSN: Readonly<Record<string, string>> = Object.freeze({
|
|
29
|
+
// hanzo-console — console.hanzo.ai (also served embedded by the cloud binary)
|
|
30
|
+
console:
|
|
31
|
+
'https://1:0c8054dbde157f4f420c56b58660052b2ad782293c4de1d606ef8fbc46a0bf34@api.hanzo.ai/v1/sentry/019fa40b-94ae-7f1d-8f7b-e92f123fad42',
|
|
32
|
+
// hanzo-app — hanzo.app
|
|
33
|
+
app: 'https://1:b3e1173125568c80f91ef4b1fabbbd2d7e22341de02b33ce7e22ef4fc16a196e@api.hanzo.ai/v1/sentry/019f9b1e-57eb-7171-9d92-72c0b85e4b4b',
|
|
34
|
+
// hanzo-ai — hanzo.ai (the marketing site; `site` is the product name it declares)
|
|
35
|
+
site: 'https://1:d9cbfb844958bd7ef2a455600f00fbf237fbd71b75c1504f137773096d6aa53f@api.hanzo.ai/v1/sentry/019f9b1e-5785-7359-ad0b-f75db8e58c99',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** dsnForProduct resolves the registered DSN for a product, or undefined when the
|
|
39
|
+
* product has no project yet — which leaves the error plane inert rather than
|
|
40
|
+
* guessing a destination and silently posting a surface's errors into the wrong
|
|
41
|
+
* project. */
|
|
42
|
+
export function dsnForProduct(product: string | undefined): string | undefined {
|
|
43
|
+
if (!product) return undefined
|
|
44
|
+
return PRODUCT_DSN[product]
|
|
45
|
+
}
|
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
|
+
})
|
package/src/funnels.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The ONE funnel registry. A funnel is DATA — an ordered list of event names —
|
|
2
|
+
// so the products, the Insights UI, and the docs can never drift: every step is
|
|
3
|
+
// a name from EVENTS (funnels.test.ts fails the build otherwise), and a GOAL that
|
|
4
|
+
// has a funnel points AT one of these rather than restating the steps.
|
|
5
|
+
//
|
|
6
|
+
// Why here and not in Insights: the taxonomy is code that all three surfaces
|
|
7
|
+
// already import (`@hanzo/event`). A funnel defined in the read lens could name
|
|
8
|
+
// an event no surface emits; a funnel defined next to EVENTS cannot.
|
|
9
|
+
|
|
10
|
+
import { EVENTS, PAGEVIEW } from './events'
|
|
11
|
+
|
|
12
|
+
/** The emitting surfaces — the closed set of `AnalyticsConfig.product` values.
|
|
13
|
+
* `product` is on every event, so a funnel scopes by product instead of every
|
|
14
|
+
* surface prefixing its event names. */
|
|
15
|
+
export const PRODUCTS = ['site', 'app', 'chat', 'console', 'admin', 'cloud'] as const
|
|
16
|
+
export type ProductId = (typeof PRODUCTS)[number]
|
|
17
|
+
|
|
18
|
+
export interface FunnelStep {
|
|
19
|
+
/** An EVENTS value (or PAGEVIEW). */
|
|
20
|
+
event: string
|
|
21
|
+
/** Human label for the Insights step. */
|
|
22
|
+
label: string
|
|
23
|
+
/** Property equality that qualifies the step, e.g. first_action{action:'api_call'}. */
|
|
24
|
+
where?: { property: string; equals: string }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FunnelDef {
|
|
28
|
+
label: string
|
|
29
|
+
/** Surface(s) the steps are emitted from — matched against the `product` field. */
|
|
30
|
+
products: ProductId[]
|
|
31
|
+
/**
|
|
32
|
+
* How steps are joined:
|
|
33
|
+
* • 'person' — steps join on distinctId (one browser, or one logged-in
|
|
34
|
+
* person across surfaces). The normal case.
|
|
35
|
+
* • 'aggregate' — steps are emitted on DIFFERENT origins by a LOGGED-OUT
|
|
36
|
+
* visitor, so there is no shared id: hanzo.ai, hanzo.app and
|
|
37
|
+
* hanzo.chat each mint their own anonymousId in their own
|
|
38
|
+
* storage. Read these as step-over-step COUNTS, never as a
|
|
39
|
+
* per-person conversion. Honest by construction.
|
|
40
|
+
*/
|
|
41
|
+
join: 'person' | 'aggregate'
|
|
42
|
+
steps: FunnelStep[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const s = (event: string, label: string, where?: FunnelStep['where']): FunnelStep => ({
|
|
46
|
+
event,
|
|
47
|
+
label,
|
|
48
|
+
...(where ? { where } : {}),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
export const FUNNELS = {
|
|
52
|
+
/** hanzo.ai: land → sign up. IAM hosts the form, so `signup_submitted` is the
|
|
53
|
+
* redirect INTO IAM and `signup_completed` is the return at /auth/callback. */
|
|
54
|
+
signup: {
|
|
55
|
+
label: 'Signup',
|
|
56
|
+
products: ['site'],
|
|
57
|
+
join: 'person',
|
|
58
|
+
steps: [
|
|
59
|
+
s(PAGEVIEW, 'Landed'),
|
|
60
|
+
s(EVENTS.SIGNUP_VIEWED, 'Opened signup'),
|
|
61
|
+
s(EVENTS.SIGNUP_SUBMITTED, 'Redirected to Hanzo ID'),
|
|
62
|
+
s(EVENTS.SIGNUP_COMPLETED, 'Account created'),
|
|
63
|
+
s(EVENTS.FIRST_ACTION, 'First action'),
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/** The developer activation path: an account is worth nothing until a key has
|
|
68
|
+
* made a call. `first_action{action:'api_call'}` is emitted SERVER-SIDE by
|
|
69
|
+
* Cloud on an org's first successful /v1 request — a browser cannot see it. */
|
|
70
|
+
apiActivation: {
|
|
71
|
+
label: 'API activation',
|
|
72
|
+
products: ['site', 'cloud'],
|
|
73
|
+
join: 'person',
|
|
74
|
+
steps: [
|
|
75
|
+
s(EVENTS.SIGNUP_COMPLETED, 'Account created'),
|
|
76
|
+
s(EVENTS.API_KEY_CREATED, 'Key minted'),
|
|
77
|
+
s(EVENTS.FIRST_ACTION, 'First successful API call', {
|
|
78
|
+
property: 'action',
|
|
79
|
+
equals: 'api_call',
|
|
80
|
+
}),
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/** Upgrade intent → revenue. `order_completed{kind:'plan'}` is the Sale goal. */
|
|
85
|
+
upgrade: {
|
|
86
|
+
label: 'Upgrade',
|
|
87
|
+
products: ['site', 'app', 'console'],
|
|
88
|
+
join: 'person',
|
|
89
|
+
steps: [
|
|
90
|
+
s(EVENTS.PRICING_VIEWED, 'Viewed pricing'),
|
|
91
|
+
s(EVENTS.PLAN_CLICKED, 'Chose a plan'),
|
|
92
|
+
s(EVENTS.CHECKOUT_STARTED, 'Started checkout'),
|
|
93
|
+
s(EVENTS.ORDER_COMPLETED, 'Paid'),
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/** hanzo.app: describe → build → deploy → live URL. The whole product thesis
|
|
98
|
+
* in five steps; `deploy_succeeded` is the moment a live URL exists. */
|
|
99
|
+
appShip: {
|
|
100
|
+
label: 'Describe → ship',
|
|
101
|
+
products: ['app'],
|
|
102
|
+
join: 'person',
|
|
103
|
+
steps: [
|
|
104
|
+
s(PAGEVIEW, 'Landed'),
|
|
105
|
+
s(EVENTS.BUILD_STARTED, 'Described an app'),
|
|
106
|
+
s(EVENTS.GENERATION_COMPLETED, 'Got a working build'),
|
|
107
|
+
s(EVENTS.DEPLOY_STARTED, 'Hit publish'),
|
|
108
|
+
s(EVENTS.DEPLOY_SUCCEEDED, 'Live URL'),
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/** hanzo.chat: visit → first message → answer. `generation_completed` is what
|
|
113
|
+
* separates "typed something" from "got value". */
|
|
114
|
+
chatEngage: {
|
|
115
|
+
label: 'Chat engagement',
|
|
116
|
+
products: ['chat'],
|
|
117
|
+
join: 'person',
|
|
118
|
+
steps: [
|
|
119
|
+
s(PAGEVIEW, 'Landed'),
|
|
120
|
+
s(EVENTS.CHAT_STARTED, 'Started a conversation'),
|
|
121
|
+
s(EVENTS.CHAT_MESSAGE_SENT, 'Sent a message'),
|
|
122
|
+
s(EVENTS.GENERATION_COMPLETED, 'Got an answer'),
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/** The cross-surface handoff: the hanzo.ai composer forwards its prompt to
|
|
127
|
+
* hanzo.chat. Two origins, two anonymousIds — so this is an AGGREGATE funnel.
|
|
128
|
+
* The join is the `referrerProduct` property hanzo.chat reads off `?hz_ref=`,
|
|
129
|
+
* which makes the drop-off measurable without any cross-domain identity. */
|
|
130
|
+
siteToChat: {
|
|
131
|
+
label: 'Site → Chat handoff',
|
|
132
|
+
products: ['site', 'chat'],
|
|
133
|
+
join: 'aggregate',
|
|
134
|
+
steps: [
|
|
135
|
+
s(EVENTS.CHAT_STARTED, 'Submitted the hanzo.ai composer', {
|
|
136
|
+
property: 'source',
|
|
137
|
+
equals: 'composer',
|
|
138
|
+
}),
|
|
139
|
+
s(EVENTS.CHAT_STARTED, 'Landed in hanzo.chat', {
|
|
140
|
+
property: 'referrerProduct',
|
|
141
|
+
equals: 'site',
|
|
142
|
+
}),
|
|
143
|
+
s(EVENTS.GENERATION_COMPLETED, 'Got an answer'),
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
} as const satisfies Record<string, FunnelDef>
|
|
147
|
+
|
|
148
|
+
export type FunnelId = keyof typeof FUNNELS
|
|
149
|
+
|
|
150
|
+
/** eventsOf flattens a funnel to its ordered event names — what a goal's `funnel`
|
|
151
|
+
* field carries, so the steps are defined exactly once (here). */
|
|
152
|
+
export function eventsOf(id: FunnelId): string[] {
|
|
153
|
+
return FUNNELS[id].steps.map((step) => step.event)
|
|
154
|
+
}
|
package/src/goals.ts
CHANGED
|
@@ -1,44 +1,58 @@
|
|
|
1
1
|
// Insights goals + cohorts, defined once as data so the console/insights UI and
|
|
2
2
|
// every product agree on what "a Signup", "a Sale", and "upgrade intent" mean.
|
|
3
3
|
// This is the machine-readable spec — the shared source of truth a sync step can
|
|
4
|
-
// push into Insights, and what
|
|
4
|
+
// push into Insights, and what TAXONOMY.md documents.
|
|
5
|
+
//
|
|
6
|
+
// A goal answers "what counts as a conversion". A funnel answers "by what path".
|
|
7
|
+
// The paths live in funnels.ts and a goal REFERENCES one by id: the steps are
|
|
8
|
+
// written down exactly once.
|
|
5
9
|
|
|
6
10
|
import { EVENTS } from './events'
|
|
11
|
+
import { eventsOf, type FunnelId } from './funnels'
|
|
7
12
|
|
|
8
13
|
export interface GoalDef {
|
|
9
14
|
/** Human label shown in Insights. */
|
|
10
15
|
label: string
|
|
11
16
|
/** The event whose occurrence counts as the goal conversion. */
|
|
12
17
|
event: string
|
|
13
|
-
/**
|
|
18
|
+
/** The funnel leading to the goal — an id into FUNNELS (see funnels.ts). */
|
|
19
|
+
funnelId?: FunnelId
|
|
20
|
+
/** The ordered event names of `funnelId`, derived — never hand-written. */
|
|
14
21
|
funnel?: string[]
|
|
15
22
|
/** Optional property equality filter that qualifies the conversion. */
|
|
16
23
|
filter?: { property: string; equals: string }
|
|
17
24
|
}
|
|
18
25
|
|
|
19
|
-
export const GOALS: Record<'signup' | 'sale' | 'upgradeIntent', GoalDef> = {
|
|
20
|
-
// Signup: the conversion is signup_completed
|
|
26
|
+
export const GOALS: Record<'signup' | 'sale' | 'upgradeIntent' | 'activation', GoalDef> = {
|
|
27
|
+
// Signup: the conversion is signup_completed, along the site signup funnel.
|
|
21
28
|
signup: {
|
|
22
29
|
label: 'Signup',
|
|
23
30
|
event: EVENTS.SIGNUP_COMPLETED,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
EVENTS.SIGNUP_SUBMITTED,
|
|
27
|
-
EVENTS.SIGNUP_VERIFIED,
|
|
28
|
-
EVENTS.FIRST_ACTION,
|
|
29
|
-
],
|
|
31
|
+
funnelId: 'signup',
|
|
32
|
+
funnel: eventsOf('signup'),
|
|
30
33
|
},
|
|
31
34
|
// Sale: a completed order qualified as a plan purchase (kind=plan).
|
|
32
35
|
sale: {
|
|
33
36
|
label: 'Sale',
|
|
34
37
|
event: EVENTS.ORDER_COMPLETED,
|
|
38
|
+
funnelId: 'upgrade',
|
|
39
|
+
funnel: eventsOf('upgrade'),
|
|
35
40
|
filter: { property: 'kind', equals: 'plan' },
|
|
36
41
|
},
|
|
37
42
|
// Upgrade intent: a plan click; pricing_viewed is the top of its funnel.
|
|
38
43
|
upgradeIntent: {
|
|
39
44
|
label: 'Upgrade Intent',
|
|
40
45
|
event: EVENTS.PLAN_CLICKED,
|
|
41
|
-
|
|
46
|
+
funnelId: 'upgrade',
|
|
47
|
+
funnel: eventsOf('upgrade'),
|
|
48
|
+
},
|
|
49
|
+
// Activation: the ONE north-star conversion — an account that did the first
|
|
50
|
+
// valuable thing (a successful API call, a live app, a chat answer).
|
|
51
|
+
activation: {
|
|
52
|
+
label: 'Activation',
|
|
53
|
+
event: EVENTS.FIRST_ACTION,
|
|
54
|
+
funnelId: 'apiActivation',
|
|
55
|
+
funnel: eventsOf('apiActivation'),
|
|
42
56
|
},
|
|
43
57
|
}
|
|
44
58
|
|
package/src/index.ts
CHANGED
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
|
|
10
10
|
export { Analytics, createAnalytics, VERSION, getCohort, getFirstTouch } from './core'
|
|
11
11
|
export { parseDsn, buildSentryEvent, buildEnvelope, framesFromStack } from './sentry'
|
|
12
|
+
export { PRODUCT_DSN, dsnForProduct } from './dsn'
|
|
12
13
|
export type { ErrorIdentity } from './sentry'
|
|
13
14
|
export { scrubText, redactSecrets, scrubPII } from './scrub'
|
|
14
15
|
export { EVENTS, PAGEVIEW } from './events'
|
|
15
16
|
export type { EventName } from './events'
|
|
16
17
|
export { GOALS, COHORTS } from './goals'
|
|
17
18
|
export type { GoalDef, CohortDef } from './goals'
|
|
19
|
+
export { FUNNELS, PRODUCTS, eventsOf } from './funnels'
|
|
20
|
+
export type { FunnelDef, FunnelStep, FunnelId, ProductId } from './funnels'
|
|
18
21
|
export {
|
|
19
22
|
parseAttribution,
|
|
20
23
|
deriveChannel,
|
package/src/scrub.ts
CHANGED
|
@@ -70,7 +70,12 @@ function redactPAN(s: string): string {
|
|
|
70
70
|
})
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
// Bounded to the RFC 5321 maxima (local-part 64, domain 255) for the same reason
|
|
74
|
+
// as the creds-in-URL rule: the unbounded form backtracks quadratically across a
|
|
75
|
+
// long run of local-part-legal characters that never reaches an '@' — measured at
|
|
76
|
+
// 20ms on an 8KB worst case, on the main thread, per captured error. No real
|
|
77
|
+
// address is excluded by these caps.
|
|
78
|
+
const RE_EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g
|
|
74
79
|
const RE_IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g
|
|
75
80
|
const RE_IPV6 = /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b/g
|
|
76
81
|
|
package/src/sentry.test.ts
CHANGED
|
@@ -258,3 +258,55 @@ describe('fingerprint grouping (port of server model)', () => {
|
|
|
258
258
|
expect(f1).not.toBe(f2)
|
|
259
259
|
})
|
|
260
260
|
})
|
|
261
|
+
|
|
262
|
+
// ── hostile thrown objects ─────────────────────────────────────────────────
|
|
263
|
+
//
|
|
264
|
+
// `name`, `message` and `stack` are ordinary getters. A thrown object is free to
|
|
265
|
+
// define any of them to throw, and the thrown value is the least trustworthy
|
|
266
|
+
// input this library handles. normalizeError must be TOTAL — buildSentryEvent
|
|
267
|
+
// throwing here meant the crash report was lost on both planes.
|
|
268
|
+
|
|
269
|
+
describe('normalizeError survives hostile input', () => {
|
|
270
|
+
const bomb = (prop: string) => {
|
|
271
|
+
const e = new Error('real message')
|
|
272
|
+
Object.defineProperty(e, prop, {
|
|
273
|
+
get() {
|
|
274
|
+
throw new Error(prop + ' bomb')
|
|
275
|
+
},
|
|
276
|
+
configurable: true,
|
|
277
|
+
})
|
|
278
|
+
return e
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (const prop of ['stack', 'message', 'name']) {
|
|
282
|
+
it(`a throwing ${prop} getter does not throw`, () => {
|
|
283
|
+
expect(() => normalizeError(bomb(prop))).not.toThrow()
|
|
284
|
+
const n = normalizeError(bomb(prop))
|
|
285
|
+
expect(typeof n.name).toBe('string')
|
|
286
|
+
expect(typeof n.message).toBe('string')
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
it('buildSentryEvent still produces a usable event from a getter bomb', () => {
|
|
291
|
+
const ev = buildSentryEvent({ error: bomb('stack'), identity: { product: 'test' } })
|
|
292
|
+
expect(ev.exception?.values[0].type).toBe('Error')
|
|
293
|
+
// The message survived because only `stack` was hostile.
|
|
294
|
+
expect(ev.exception?.values[0].value).toBe('real message')
|
|
295
|
+
expect(ev.exception?.values[0].stacktrace?.frames).toEqual([])
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('an object with a throwing toString is still reportable', () => {
|
|
299
|
+
const evil = {
|
|
300
|
+
toString() {
|
|
301
|
+
throw new Error('toString bomb')
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
expect(() => normalizeError(evil)).not.toThrow()
|
|
305
|
+
expect(() => buildSentryEvent({ error: evil, identity: {} })).not.toThrow()
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('a frozen/null-prototype throwable is still reportable', () => {
|
|
309
|
+
const weird = Object.freeze(Object.create(null)) as unknown
|
|
310
|
+
expect(() => buildSentryEvent({ error: weird, identity: {} })).not.toThrow()
|
|
311
|
+
})
|
|
312
|
+
})
|
package/src/sentry.ts
CHANGED
|
@@ -148,13 +148,43 @@ export function framesFromStack(stack: string | undefined): SentryFrame[] {
|
|
|
148
148
|
/** normalizeError coerces an unknown throwable into {name, message, stack}. */
|
|
149
149
|
export function normalizeError(err: unknown): { name: string; message: string; stack?: string } {
|
|
150
150
|
if (err instanceof Error) {
|
|
151
|
-
|
|
151
|
+
// `name`, `message` and `stack` are ordinary getters, and the thrown object is
|
|
152
|
+
// the least trustworthy input this library handles — any of them may throw.
|
|
153
|
+
// Losing the whole report to a hostile getter is not acceptable.
|
|
154
|
+
const name = read(err, 'name')
|
|
155
|
+
const message = read(err, 'message')
|
|
156
|
+
const stack = read(err, 'stack')
|
|
157
|
+
return {
|
|
158
|
+
name: typeof name === 'string' && name ? name : 'Error',
|
|
159
|
+
message: typeof message === 'string' && message ? message : str(err),
|
|
160
|
+
stack: typeof stack === 'string' ? stack : undefined,
|
|
161
|
+
}
|
|
152
162
|
}
|
|
153
163
|
if (typeof err === 'string') return { name: 'Error', message: err }
|
|
154
164
|
try {
|
|
155
|
-
return { name: 'Error', message: JSON.stringify(err) }
|
|
165
|
+
return { name: 'Error', message: JSON.stringify(err) ?? str(err) }
|
|
166
|
+
} catch {
|
|
167
|
+
return { name: 'Error', message: str(err) }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** read pulls a property off a possibly-hostile value without letting a throwing
|
|
172
|
+
* getter escape. */
|
|
173
|
+
function read(o: unknown, k: string): unknown {
|
|
174
|
+
try {
|
|
175
|
+
return (o as Record<string, unknown>)[k]
|
|
176
|
+
} catch {
|
|
177
|
+
return undefined
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** str coerces to a string without letting a throwing toString/Symbol.toPrimitive
|
|
182
|
+
* escape. */
|
|
183
|
+
function str(v: unknown): string {
|
|
184
|
+
try {
|
|
185
|
+
return String(v)
|
|
156
186
|
} catch {
|
|
157
|
-
return
|
|
187
|
+
return '[unstringifiable]'
|
|
158
188
|
}
|
|
159
189
|
}
|
|
160
190
|
|
package/src/version.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// The library version, stamped on every event (`libraryVersion`) and on the
|
|
2
2
|
// Sentry `sdk` block. It lives alone so `sentry.ts` can read it without importing
|
|
3
3
|
// `core.ts` — core imports sentry, so the reverse would be an import cycle.
|
|
4
|
-
export const VERSION = '0.3.
|
|
4
|
+
export const VERSION = '0.3.3'
|