@hanzo/event 0.3.3 → 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/TAXONOMY.md +257 -0
- package/dist/index.cjs +17 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -1
- package/dist/index.d.ts +34 -1
- package/dist/index.mjs +16 -2
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +15 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +15 -1
- package/dist/react.mjs.map +1 -1
- package/package.json +4 -3
- package/src/core.test.ts +9 -4
- package/src/core.ts +7 -3
- package/src/dsn.test.ts +62 -0
- package/src/dsn.ts +45 -0
- package/src/index.ts +1 -0
package/TAXONOMY.md
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# Hanzo product analytics — the taxonomy
|
|
2
|
+
|
|
3
|
+
The canonical event vocabulary, property rules, identity semantics, and funnels
|
|
4
|
+
for **hanzo.ai**, **hanzo.app**, and **hanzo.chat**. One definition, three
|
|
5
|
+
surfaces.
|
|
6
|
+
|
|
7
|
+
## Why this document lives here
|
|
8
|
+
|
|
9
|
+
The taxonomy is **code**, not prose: `src/events.ts` (`EVENTS`), `src/funnels.ts`
|
|
10
|
+
(`FUNNELS`, `PRODUCTS`), `src/goals.ts` (`GOALS`, `COHORTS`). All three apps
|
|
11
|
+
already `import { EVENTS } from '@hanzo/event'`, so this package is the only
|
|
12
|
+
place a definition can live and be *used* rather than merely described.
|
|
13
|
+
|
|
14
|
+
It is deliberately **not** in `hanzo/insights`. Insights is the read lens — it
|
|
15
|
+
renders whatever arrives. A funnel defined there could name an event no surface
|
|
16
|
+
emits and nobody would find out; a funnel defined next to `EVENTS` cannot, because
|
|
17
|
+
`funnels.test.ts` fails the build. Docs that sit apart from the constants they
|
|
18
|
+
describe drift within a release. These do not.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
@hanzo/event ── EVENTS ──┬── hanzo.ai (product: 'site')
|
|
22
|
+
FUNNELS ├── hanzo.app (product: 'app')
|
|
23
|
+
GOALS ├── hanzo.chat (product: 'chat')
|
|
24
|
+
└── insights.hanzo.ai (reads; never defines)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 1. The rules
|
|
30
|
+
|
|
31
|
+
### Naming
|
|
32
|
+
|
|
33
|
+
| Rule | Yes | No |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `snake_case`, `<object>_<verb-past>` | `deploy_succeeded` | `DeploySuccess`, `deploy-ok` |
|
|
36
|
+
| Names are constants, never interpolated | `deploy_succeeded` + `{framework:'static'}` | `deploy_static_succeeded` |
|
|
37
|
+
| Dimensions are properties | `plan_clicked` + `{plan:'pro'}` | `pro_plan_clicked` |
|
|
38
|
+
| No product prefix — `product` is already on the wire | `chat_started` | `chat_app_chat_started` |
|
|
39
|
+
| `$` is reserved for the client | `$pageview` (emitted by `pageview()`) | any app-authored `$name` |
|
|
40
|
+
| One name per user-visible moment, shared by all surfaces | `signup_completed` everywhere | `signup_done` / `registered` |
|
|
41
|
+
|
|
42
|
+
Enforced by `funnels.test.ts` (`event vocabulary` block). Adding a name means
|
|
43
|
+
adding it to `EVENTS` — a raw string in `capture()` is a bug, and the string will
|
|
44
|
+
be invisible to every funnel.
|
|
45
|
+
|
|
46
|
+
### Properties
|
|
47
|
+
|
|
48
|
+
Every event already carries, with no app code: `messageId`, `type`, `timestamp`,
|
|
49
|
+
`distinctId`, `anonymousId`, `personId`, `sessionId`, `product`, `referrer`,
|
|
50
|
+
`utm.*`, `channel`, `refCode`, `signupWeek`, `library`, `libraryVersion`. **Never
|
|
51
|
+
re-send these** as properties.
|
|
52
|
+
|
|
53
|
+
App-supplied properties:
|
|
54
|
+
|
|
55
|
+
- **Low cardinality, enumerated.** `mode`, `framework`, `plan`, `source`,
|
|
56
|
+
`endpoint`, `model`, `provider`, `status`, `reason`. A property whose value
|
|
57
|
+
space is unbounded is a log line, not a dimension.
|
|
58
|
+
- **Never user content.** No prompt, message, project name, file path, URL,
|
|
59
|
+
email, or org display name. The client is PII-free by construction; keep it so.
|
|
60
|
+
- **Booleans read as predicates**: `hasPrompt`, `hasImages`, `isUpdate`.
|
|
61
|
+
- **Durations are `durationMs`** (number, milliseconds). Outcome events own their
|
|
62
|
+
duration, which is why there is no paired `*_started` for generations.
|
|
63
|
+
- **Money never goes in properties** — use the commerce fields on `capture()`:
|
|
64
|
+
`capture(EVENTS.ORDER_COMPLETED, { kind: 'plan' }, { productId, revenue, currency, quantity })`.
|
|
65
|
+
- **Errors are events.** `captureError(err, { properties: { where: 'publish' } })`
|
|
66
|
+
puts the exception in the sentry lens *on the same stream*, so a funnel drop
|
|
67
|
+
joins to the exception that caused it. Always pass `where`.
|
|
68
|
+
|
|
69
|
+
### Identity — `identify` and `group`
|
|
70
|
+
|
|
71
|
+
Exactly one place per app, mounted where the session resolves. Never at a call
|
|
72
|
+
site.
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
analytics.identify(user.id) // the stable IAM subject (OIDC `sub`). NEVER email.
|
|
76
|
+
analytics.group(orgId) // the org
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- **`identify`** joins a person's pre- and post-login events, and joins them
|
|
80
|
+
across surfaces (the same subject arrives from Cloud server-side). Traits, if
|
|
81
|
+
any, must be non-PII (`plan`, `role`).
|
|
82
|
+
- **`group`** is what makes org-level questions answerable — *"which orgs stalled
|
|
83
|
+
before their first deploy?"* Cloud already resolves the tenant server-side for
|
|
84
|
+
billing and stamps it; `group()` is the analytics dimension, and without it no
|
|
85
|
+
B2B funnel exists. It was missing everywhere before this revision.
|
|
86
|
+
- **`setCohort({ signupWeek })`** is stamped once, at account birth, so every
|
|
87
|
+
later event carries the acquisition week and retention curves are free.
|
|
88
|
+
- **Never** send email, name, or phone. Not as a trait, not as a property.
|
|
89
|
+
|
|
90
|
+
### Cross-origin identity — the honest limit
|
|
91
|
+
|
|
92
|
+
hanzo.ai, hanzo.app, and hanzo.chat are **different origins**. A logged-out
|
|
93
|
+
visitor therefore has a **different `anonymousId` on each**; nothing joins them.
|
|
94
|
+
Only `personId` (post-login) spans surfaces.
|
|
95
|
+
|
|
96
|
+
So a funnel that crosses origins while logged out is declared
|
|
97
|
+
`join: 'aggregate'` in `FUNNELS` and read as step-over-step **counts**, never as
|
|
98
|
+
a per-person conversion rate. The join key is an explicit property: the hanzo.ai
|
|
99
|
+
composer appends `?hz_ref=site`, and hanzo.chat stamps `referrerProduct:'site'`
|
|
100
|
+
on its own `chat_started`. Measurable, without cross-domain tracking.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 2. The funnels
|
|
105
|
+
|
|
106
|
+
Machine-readable in `src/funnels.ts`; `GOALS` reference them by id.
|
|
107
|
+
|
|
108
|
+
### hanzo.ai — land → signup → activation
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
$pageview ──▶ signup_viewed ──▶ signup_submitted ──▶ signup_completed ──▶ first_action
|
|
112
|
+
(land) (/signup) (redirect to IAM) (/auth/callback) {action:'api_call'}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`FUNNELS.signup` + `FUNNELS.apiActivation`. IAM hosts the form, so `submitted` is
|
|
116
|
+
the redirect *into* IAM and `completed` is the return. `first_action{action:
|
|
117
|
+
'api_call'}` is emitted **server-side by Cloud** on an org's first successful
|
|
118
|
+
`/v1` request — a browser cannot observe it, and this is the only step that
|
|
119
|
+
proves the account was worth acquiring.
|
|
120
|
+
|
|
121
|
+
Secondary: `FUNNELS.upgrade` (`pricing_viewed → plan_clicked → checkout_started →
|
|
122
|
+
order_completed`) and `FUNNELS.siteToChat` (the composer handoff, aggregate).
|
|
123
|
+
|
|
124
|
+
### hanzo.app — describe → build → ship
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
$pageview ──▶ build_started ──▶ generation_completed ──▶ deploy_started ──▶ deploy_succeeded
|
|
128
|
+
(land) (composer) (working build) (publish) (live URL)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`FUNNELS.appShip`. The product thesis in five steps. `deploy_succeeded` fires
|
|
132
|
+
only when a live URL is in hand — never inferred from "started and no error".
|
|
133
|
+
The first one also emits `first_action{action:'app_live'}`.
|
|
134
|
+
|
|
135
|
+
### hanzo.chat — visit → first message → answer
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
$pageview ──▶ chat_started ──▶ chat_message_sent ──▶ generation_completed
|
|
139
|
+
(land) (new convo) (per message) (answer, durationMs)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`FUNNELS.chatEngage`. `chat_started` carries `referrerProduct` when the visitor
|
|
143
|
+
came from the hanzo.ai composer. `model_switched` is the quality signal that
|
|
144
|
+
sits *beside* the funnel: a switch mid-conversation usually follows a bad answer,
|
|
145
|
+
and `fromModel`/`model` is what makes "which model gets abandoned" answerable.
|
|
146
|
+
Return/retention is a cohort question (`signupWeek` × `$pageview`), not an event.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 3. Emit sites — what exists, what is missing
|
|
151
|
+
|
|
152
|
+
Verified against the trees at the time of writing. ✅ = emitting, ❌ = not emitted.
|
|
153
|
+
|
|
154
|
+
### hanzo.ai (`~/work/hanzo/hanzo.ai`, `product: 'site'`)
|
|
155
|
+
|
|
156
|
+
| Event | Where | State |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `$pageview` | `app/providers.tsx` (`AnalyticsProvider` + `usePageview`) | ✅ |
|
|
159
|
+
| `identify` / `group` | `app/providers.tsx:72,75` (`Identity`) | ✅ **added** |
|
|
160
|
+
| `signup_viewed` | `app/(marketing)/signup/page.tsx:19` | ✅ |
|
|
161
|
+
| `referral_used` | `app/(marketing)/signup/page.tsx:21` | ✅ |
|
|
162
|
+
| `signup_submitted` | `app/(marketing)/signup/page.tsx:24` | ✅ |
|
|
163
|
+
| `signup_completed` | `app/(marketing)/auth/callback/page.tsx:39` | ✅ **added** |
|
|
164
|
+
| `login_completed` | `app/(marketing)/auth/callback/page.tsx:41` | ✅ **added** |
|
|
165
|
+
| `setCohort({signupWeek})` | `app/(marketing)/auth/callback/page.tsx:38` | ✅ **added** |
|
|
166
|
+
| auth-exchange failure | `app/(marketing)/auth/callback/page.tsx:49` | ✅ **added** |
|
|
167
|
+
| `chat_started` | `components/home/ChatHero.tsx:58,66`, `components/home/LandingNav.tsx:90` | ✅ |
|
|
168
|
+
| `feature_used` | `components/home/ChatHero.tsx:63` | ✅ |
|
|
169
|
+
| `pricing_viewed` | `app/(marketing)/pricing/page.tsx:34` | ✅ |
|
|
170
|
+
| `plan_clicked` | `components/pricing/PricingPlan.tsx:43` | ✅ |
|
|
171
|
+
| `waitlist_joined` / `_shared` | `app/(marketing)/research-access/page.tsx:95`, `components/referrals/ReferralLink.tsx:27,47` | ✅ |
|
|
172
|
+
| `referral_claimed` | `components/referrals/TryFreeCoupon.tsx:32` | ✅ |
|
|
173
|
+
| `checkout_started` | `app/(marketing)/account/billing-plans/page.tsx:90` (`checkout(planId)`) | ❌ **missing** |
|
|
174
|
+
| `order_completed` | — (server-side; commerce owns the truth) | ❌ **missing** |
|
|
175
|
+
| `api_key_created` | — (no key UI on this site; console.hanzo.ai owns it) | ❌ n/a here |
|
|
176
|
+
| `first_action{action:'api_call'}` | — (Cloud, on first successful `/v1` request) | ❌ **missing — server-side** |
|
|
177
|
+
|
|
178
|
+
### hanzo.app (`~/work/hanzo/app`, `product: 'app'`)
|
|
179
|
+
|
|
180
|
+
| Event | Where | State |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `$pageview`, errors | `components/providers/analytics.tsx` (`AnalyticsRoot`) | ✅ |
|
|
183
|
+
| `identify` | `components/providers/analytics.tsx:47` | ✅ |
|
|
184
|
+
| `group` | `components/providers/analytics.tsx:55` | ✅ **added** |
|
|
185
|
+
| `build_started` | `components/build-composer/index.tsx:173` | ✅ **fixed** (was `app_created` — intent mislabelled as creation) |
|
|
186
|
+
| `chat_message_sent` | `components/chat-panel/index.tsx:242` | ✅ |
|
|
187
|
+
| `generation_completed` | `components/workspace/index.tsx:1051` | ✅ **added** |
|
|
188
|
+
| `generation_failed` | `components/workspace/index.tsx:1065,1092` | ✅ **added** |
|
|
189
|
+
| `project_created` | `components/project-manager/index.tsx:299` | ✅ |
|
|
190
|
+
| `deploy_started` | `components/editor/deploy-button/content.tsx:61` | ✅ |
|
|
191
|
+
| `deploy_succeeded` | `components/editor/deploy-button/content.tsx:156` | ✅ **added** |
|
|
192
|
+
| `first_action{action:'app_live'}` | `components/editor/deploy-button/content.tsx:164` | ✅ **added** |
|
|
193
|
+
| `deploy_failed` | `components/editor/deploy-button/content.tsx:95,183` | ✅ **added** |
|
|
194
|
+
| `pricing_viewed` / `plan_clicked` | `app/pricing/page.tsx:87,99` | ✅ |
|
|
195
|
+
| `signup_viewed` / `_submitted` | `app/signup/page.tsx:26,30` | ✅ |
|
|
196
|
+
| `signup_completed` | — (this app has no IAM callback route of its own) | ❌ **missing** |
|
|
197
|
+
|
|
198
|
+
### hanzo.chat (`~/work/hanzo/chat`, `product: 'chat'`)
|
|
199
|
+
|
|
200
|
+
| Event | Where | State |
|
|
201
|
+
|---|---|---|
|
|
202
|
+
| `$pageview` | `client/src/Providers/AnalyticsProvider.tsx` | ✅ |
|
|
203
|
+
| `identify` | `client/src/Providers/AnalyticsProvider.tsx` (`AnalyticsBridge`) | ✅ |
|
|
204
|
+
| `chat_started` (+ `referrerProduct`) | `client/src/hooks/Chat/useChatFunctions.ts:138` | ✅ **enriched** |
|
|
205
|
+
| `chat_message_sent` | `client/src/hooks/Chat/useChatFunctions.ts:144` | ✅ |
|
|
206
|
+
| `generation_completed` | `client/src/hooks/SSE/useSSE.ts:138` | ✅ **added** |
|
|
207
|
+
| `generation_failed` | `client/src/hooks/SSE/useSSE.ts:251` | ✅ **added** |
|
|
208
|
+
| `model_switched` | `client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx:233` | ✅ **added** |
|
|
209
|
+
| `group` | — (no org concept in the chat session today) | ❌ **missing** |
|
|
210
|
+
| `signup_completed` | — (IAM session-bridge, no callback route here) | ❌ **missing** |
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 4. Known gaps — ranked
|
|
215
|
+
|
|
216
|
+
1. **hanzo.app runs a second, parallel telemetry pipe.** `lib/telemetry/`
|
|
217
|
+
(`config.ts`, `tracker.ts`, `events.ts`) POSTs its own vocabulary
|
|
218
|
+
(`task_started`, `task_complete`, `task_fail`, `model_selected`, `pageview`,
|
|
219
|
+
`heartbeat`) with its own visitor id and its own batching to
|
|
220
|
+
`https://console.hanzo.ai/api/public/otel/v1/traces`. That is a **frontend
|
|
221
|
+
host**, with an **`/api/` prefix**, and a vocabulary disjoint from this one —
|
|
222
|
+
so nothing in that stream can be joined to a funnel in this one. Both fire
|
|
223
|
+
today at `components/workspace/index.tsx` (`track('task_complete')` beside
|
|
224
|
+
`capture(GENERATION_COMPLETED)`).
|
|
225
|
+
*Fix:* reduce `lib/telemetry`'s `track()` to a thin adapter over the shared
|
|
226
|
+
`@hanzo/event` client (name-map `task_complete → generation_completed`,
|
|
227
|
+
`model_selected → model_switched`), delete `tracker.ts` + `config.ts`, and the
|
|
228
|
+
duplicate queue/retry/heartbeat/visitor-id machinery goes with it.
|
|
229
|
+
2. **`first_action` is emitted by nobody on the API path.** Activation — the one
|
|
230
|
+
metric that matters — needs Cloud to emit `first_action{action:'api_call'}`
|
|
231
|
+
on an org's first successful `/v1` request. `FUNNELS.apiActivation` is
|
|
232
|
+
specified and unmeasurable until it does. `hanzo.app` now covers its own
|
|
233
|
+
variant (`action:'app_live'`).
|
|
234
|
+
3. **`checkout_started` / `order_completed` are unwired.** `hanzo.ai`'s
|
|
235
|
+
`account/billing-plans/page.tsx:90` calls `checkout(planId)` and captures
|
|
236
|
+
nothing, so `GOALS.sale` has no data and `FUNNELS.upgrade` truncates at
|
|
237
|
+
`plan_clicked`.
|
|
238
|
+
4. **No org on hanzo.chat.** `group()` is absent because the chat session carries
|
|
239
|
+
no org today; chat funnels are person-scoped only.
|
|
240
|
+
5. **Logged-out reach depends on a publishable key.** Anonymous events need
|
|
241
|
+
`ingestKey` (`pk_…`, write-only) or they fail closed at the door.
|
|
242
|
+
`NEXT_PUBLIC_HANZO_INGEST_KEY` (hanzo.ai), `NEXT_PUBLIC_EVENT_INGEST_KEY`
|
|
243
|
+
(hanzo.app), `VITE_HANZO_INGEST_KEY` (hanzo.chat) are read but must be
|
|
244
|
+
provisioned per org via `POST /v1/ingest/keys`. Config, not code.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## 5. Adding an event
|
|
249
|
+
|
|
250
|
+
1. Add the constant to `EVENTS` in `src/events.ts` — with a comment saying which
|
|
251
|
+
decision it informs. If you cannot name one, do not add it.
|
|
252
|
+
2. If it is a funnel step, add it to the funnel in `src/funnels.ts`. If it is a
|
|
253
|
+
conversion, point a `GOALS` entry at that funnel id.
|
|
254
|
+
3. `pnpm test` — the drift guard will reject a step whose event does not exist.
|
|
255
|
+
4. Emit it from **one** place per app, with enumerated properties only.
|
|
256
|
+
5. Bump the patch version; the `hanzoai/ui` publish workflow ships it on merge to
|
|
257
|
+
`main`, and apps pick it up on their next install.
|
package/dist/index.cjs
CHANGED
|
@@ -73,6 +73,20 @@ function isoWeek(d) {
|
|
|
73
73
|
return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// src/dsn.ts
|
|
77
|
+
var PRODUCT_DSN = Object.freeze({
|
|
78
|
+
// hanzo-console — console.hanzo.ai (also served embedded by the cloud binary)
|
|
79
|
+
console: "https://1:0c8054dbde157f4f420c56b58660052b2ad782293c4de1d606ef8fbc46a0bf34@api.hanzo.ai/v1/sentry/019fa40b-94ae-7f1d-8f7b-e92f123fad42",
|
|
80
|
+
// hanzo-app — hanzo.app
|
|
81
|
+
app: "https://1:b3e1173125568c80f91ef4b1fabbbd2d7e22341de02b33ce7e22ef4fc16a196e@api.hanzo.ai/v1/sentry/019f9b1e-57eb-7171-9d92-72c0b85e4b4b",
|
|
82
|
+
// hanzo-ai — hanzo.ai (the marketing site; `site` is the product name it declares)
|
|
83
|
+
site: "https://1:d9cbfb844958bd7ef2a455600f00fbf237fbd71b75c1504f137773096d6aa53f@api.hanzo.ai/v1/sentry/019f9b1e-5785-7359-ad0b-f75db8e58c99"
|
|
84
|
+
});
|
|
85
|
+
function dsnForProduct(product) {
|
|
86
|
+
if (!product) return void 0;
|
|
87
|
+
return PRODUCT_DSN[product];
|
|
88
|
+
}
|
|
89
|
+
|
|
76
90
|
// src/events.ts
|
|
77
91
|
var EVENTS = {
|
|
78
92
|
// Signup funnel: view -> submit -> verify -> completed -> first action.
|
|
@@ -593,7 +607,7 @@ var Analytics = class {
|
|
|
593
607
|
...config
|
|
594
608
|
};
|
|
595
609
|
this.transport = config.transport ?? new DefaultTransport();
|
|
596
|
-
this.dsn = parseDsn(config.dsn ?? readEnvDsn());
|
|
610
|
+
this.dsn = parseDsn(config.dsn ?? readEnvDsn() ?? dsnForProduct(this.cfg.product));
|
|
597
611
|
}
|
|
598
612
|
/** errorPlaneEnabled reports whether captured exceptions can actually reach the
|
|
599
613
|
* error host. False means a DSN was never configured — the documented
|
|
@@ -955,11 +969,13 @@ exports.FUNNELS = FUNNELS;
|
|
|
955
969
|
exports.GOALS = GOALS;
|
|
956
970
|
exports.PAGEVIEW = PAGEVIEW;
|
|
957
971
|
exports.PRODUCTS = PRODUCTS;
|
|
972
|
+
exports.PRODUCT_DSN = PRODUCT_DSN;
|
|
958
973
|
exports.VERSION = VERSION;
|
|
959
974
|
exports.buildEnvelope = buildEnvelope;
|
|
960
975
|
exports.buildSentryEvent = buildSentryEvent;
|
|
961
976
|
exports.createAnalytics = createAnalytics;
|
|
962
977
|
exports.deriveChannel = deriveChannel;
|
|
978
|
+
exports.dsnForProduct = dsnForProduct;
|
|
963
979
|
exports.eventsOf = eventsOf;
|
|
964
980
|
exports.framesFromStack = framesFromStack;
|
|
965
981
|
exports.getCohort = getCohort;
|