@stetcms/analytics 0.1.0 → 0.2.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/README.md +106 -17
- package/dist/client.d.ts +5 -2
- package/dist/client.js +6 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -2
- package/dist/plan-C6WUN0RK.js +61 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +3 -1
- package/dist/wire-CZwVGiU5.js +149 -0
- package/package.json +13 -5
- package/dist/events-DnYdjoR6.js +0 -57
- package/dist/wire-J3xnrDYj.js +0 -143
package/README.md
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
# @stetcms/analytics
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
[](https://github.com/jamiedavenport/stet/actions/workflows/ci.yml)
|
|
4
|
+
[](https://docs.stetcms.com/reference/analytics)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
Product analytics for apps built on [Stet](https://stetcms.com), the CMS where
|
|
8
|
+
marketing owns the content model and engineering gets a typed client generated
|
|
9
|
+
from it. Pageviews and your own typed events, routed through your backend
|
|
10
|
+
instead of a third-party endpoint.
|
|
5
11
|
|
|
6
12
|
- No cookies, so no consent banner.
|
|
7
13
|
- No third-party origin in the page, so nothing for a blocker to match on.
|
|
@@ -9,6 +15,9 @@ routed through your backend instead of a third-party endpoint.
|
|
|
9
15
|
- One tracking plan types the browser calls, validates the server ones, and
|
|
10
16
|
becomes the event list your content team builds dashboards from.
|
|
11
17
|
|
|
18
|
+
The [analytics guide](https://docs.stetcms.com/analytics) covers how this fits
|
|
19
|
+
with the dashboards your content team builds on the same events.
|
|
20
|
+
|
|
12
21
|
## Install
|
|
13
22
|
|
|
14
23
|
```bash
|
|
@@ -18,7 +27,10 @@ npm install @stetcms/analytics
|
|
|
18
27
|
## 1. Declare the tracking plan
|
|
19
28
|
|
|
20
29
|
The plan goes in `stet.config.ts` at the project root, the one file
|
|
21
|
-
`@stetcms/vite` and the
|
|
30
|
+
[`@stetcms/vite`](https://docs.stetcms.com/reference/codegen) and the
|
|
31
|
+
[`stet` CLI](https://docs.stetcms.com/reference/cli) read for everything
|
|
32
|
+
(see [`@stetcms/config`](https://docs.stetcms.com/reference/configuration)).
|
|
33
|
+
Props take any
|
|
22
34
|
[Standard Schema](https://standardschema.dev) validator, so Zod, Valibot and
|
|
23
35
|
ArkType all work.
|
|
24
36
|
|
|
@@ -80,6 +92,31 @@ type fails the build. It never throws and never rejects, so a tracking mistake
|
|
|
80
92
|
cannot break the page. Events batch and flush every two seconds, when twenty
|
|
81
93
|
are queued, and when the page is hidden or unloaded.
|
|
82
94
|
|
|
95
|
+
## 4. Track from your server too
|
|
96
|
+
|
|
97
|
+
A signup or a subscription belongs to your backend, and recording it there is
|
|
98
|
+
what stops an ad blocker or a closed tab losing it. The same client does this:
|
|
99
|
+
it guards its listeners behind a `window` check, so it runs anywhere `fetch`
|
|
100
|
+
does. Point it at an absolute URL and it posts to the route you already
|
|
101
|
+
mounted, which keeps your key in one place.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const analytics = createAnalytics<(typeof config)['analytics']>({
|
|
105
|
+
endpoint: `${process.env.APP_URL}/api/analytics`,
|
|
106
|
+
context: { userId },
|
|
107
|
+
autoPageviews: false,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
analytics.track('subscription.started', { plan: 'paid' });
|
|
111
|
+
await analytics.flush();
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`context` is fixed when the client is built, so build one per unit of work
|
|
115
|
+
rather than let two concurrent requests stamp each other's identity on their
|
|
116
|
+
events. Nothing on a server fires `pagehide`, so call `flush()` yourself; on
|
|
117
|
+
Cloudflare Workers, `waitUntil(analytics.flush())` sends it without holding up
|
|
118
|
+
the response.
|
|
119
|
+
|
|
83
120
|
## Pageviews
|
|
84
121
|
|
|
85
122
|
On a site where every navigation is a real page load — plain HTML, Astro
|
|
@@ -95,6 +132,11 @@ knows; ask it.
|
|
|
95
132
|
A repeated view of the same URL counts once either way, so the double-invoked
|
|
96
133
|
effects of React Strict Mode do not inflate anything.
|
|
97
134
|
|
|
135
|
+
Pass the router's parameterized template as `route` when it exposes one. Stet
|
|
136
|
+
then groups `/blog/first` and `/blog/second` under `/blog/[slug]`. Templates
|
|
137
|
+
are framework-native: `$slug`, `[slug]`, and `:slug` all work. When `route` is
|
|
138
|
+
absent, Stet groups by the concrete pathname as before.
|
|
139
|
+
|
|
98
140
|
```ts
|
|
99
141
|
export const analytics = createAnalytics<(typeof config)['analytics']>({
|
|
100
142
|
endpoint: '/api/analytics',
|
|
@@ -114,14 +156,17 @@ every other one. Every snippet below passes it explicitly for that reason.
|
|
|
114
156
|
### TanStack Router
|
|
115
157
|
|
|
116
158
|
```tsx
|
|
117
|
-
import { useLocation } from '@tanstack/react-router';
|
|
159
|
+
import { useLocation, useRouterState } from '@tanstack/react-router';
|
|
118
160
|
import { useEffect } from 'react';
|
|
119
161
|
|
|
120
162
|
export function usePageviews() {
|
|
121
163
|
const location = useLocation();
|
|
164
|
+
const route = useRouterState({
|
|
165
|
+
select: (state) => state.matches.at(-1)?.fullPath,
|
|
166
|
+
});
|
|
122
167
|
useEffect(() => {
|
|
123
|
-
analytics.pageview(`${window.location.origin}${location.href}
|
|
124
|
-
}, [location.href]);
|
|
168
|
+
analytics.pageview(`${window.location.origin}${location.href}`, { route });
|
|
169
|
+
}, [location.href, route]);
|
|
125
170
|
}
|
|
126
171
|
```
|
|
127
172
|
|
|
@@ -146,6 +191,8 @@ export function Pageviews() {
|
|
|
146
191
|
|
|
147
192
|
Render it in `app/layout.tsx`. `useSearchParams` opts the subtree into client
|
|
148
193
|
rendering, so wrap it in `<Suspense>` to keep the rest of the layout static.
|
|
194
|
+
The App Router does not expose its route template to client components, so
|
|
195
|
+
this falls back to the concrete pathname unless your app supplies one.
|
|
149
196
|
|
|
150
197
|
### React Router
|
|
151
198
|
|
|
@@ -169,7 +216,7 @@ In `+layout.svelte`:
|
|
|
169
216
|
```svelte
|
|
170
217
|
<script>
|
|
171
218
|
import { afterNavigate } from '$app/navigation';
|
|
172
|
-
afterNavigate(({ to }) => analytics.pageview(to?.url.href));
|
|
219
|
+
afterNavigate(({ to }) => analytics.pageview(to?.url.href, { route: to?.route.id ?? undefined }));
|
|
173
220
|
</script>
|
|
174
221
|
```
|
|
175
222
|
|
|
@@ -180,22 +227,34 @@ In a client-only plugin, `plugins/analytics.client.ts`:
|
|
|
180
227
|
```ts
|
|
181
228
|
export default defineNuxtPlugin(() => {
|
|
182
229
|
useRouter().afterEach((to) => {
|
|
183
|
-
|
|
230
|
+
const route = to.matched.at(-1)?.path;
|
|
231
|
+
analytics.pageview(`${window.location.origin}${to.fullPath}`, { route });
|
|
184
232
|
});
|
|
185
233
|
});
|
|
186
234
|
```
|
|
187
235
|
|
|
188
236
|
### Astro
|
|
189
237
|
|
|
190
|
-
Astro navigations are full
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
event fires after the swap, when `window.location` is already the new page:
|
|
238
|
+
Astro navigations are full-page loads, so the default is already correct. With
|
|
239
|
+
view transitions enabled, render the current pattern and read it after each
|
|
240
|
+
page swap:
|
|
194
241
|
|
|
195
|
-
```
|
|
196
|
-
|
|
242
|
+
```astro
|
|
243
|
+
<meta name="stet-route" content={Astro.routePattern} />
|
|
244
|
+
|
|
245
|
+
<script>
|
|
246
|
+
import { analytics } from '../analytics';
|
|
247
|
+
|
|
248
|
+
document.addEventListener('astro:page-load', () => {
|
|
249
|
+
const route = document.querySelector('meta[name="stet-route"]')?.getAttribute('content');
|
|
250
|
+
analytics.pageview(window.location.href, { route: route ?? undefined });
|
|
251
|
+
});
|
|
252
|
+
</script>
|
|
197
253
|
```
|
|
198
254
|
|
|
255
|
+
Astro 5 exposes the template as `Astro.routePattern` while rendering. The meta
|
|
256
|
+
element is replaced during navigation, so the listener reads the new pattern.
|
|
257
|
+
|
|
199
258
|
## Context vs metadata
|
|
200
259
|
|
|
201
260
|
Two different trust models, worth keeping straight:
|
|
@@ -245,12 +304,16 @@ because a `4xx` only teaches a crawler to retry.
|
|
|
245
304
|
### `@stetcms/analytics/sync`
|
|
246
305
|
|
|
247
306
|
`syncTrackingPlan(options)` publishes the plan to Stet. Called for you by
|
|
248
|
-
`@stetcms/vite` on dev-server
|
|
249
|
-
|
|
307
|
+
[`@stetcms/vite`](https://docs.stetcms.com/reference/codegen) on dev-server
|
|
308
|
+
and build start, and by
|
|
309
|
+
[`stet sync`](https://docs.stetcms.com/reference/cli#stet-sync); you rarely
|
|
310
|
+
call it yourself.
|
|
250
311
|
|
|
251
312
|
### `@stetcms/analytics/client`
|
|
252
313
|
|
|
253
314
|
`createAnalytics(options)` → `{ track, pageview, setContext, flush }`.
|
|
315
|
+
`pageview(url?, options?: { route? })` keeps the concrete URL for deduplication and uses
|
|
316
|
+
the optional framework route template for grouping.
|
|
254
317
|
|
|
255
318
|
| Option | Default | Meaning |
|
|
256
319
|
| --------------- | -------- | ------------------------------------------ |
|
|
@@ -269,6 +332,7 @@ because a `4xx` only teaches a crawler to retry.
|
|
|
269
332
|
| `context` | `{}` | Props, or a function of the request, that win over the browser's |
|
|
270
333
|
| `origin` | `STET_ORIGIN`, then the cloud | Stet deployment to forward to |
|
|
271
334
|
| `apiKey` | `STET_API_KEY` | Organization API key |
|
|
335
|
+
| `enabled` | whether a key resolved | Whether to forward batches at all |
|
|
272
336
|
| `salt` | the request's host | Mixed into the visitor digest |
|
|
273
337
|
| `onError` | `console.error` | Called when a batch cannot be forwarded |
|
|
274
338
|
|
|
@@ -277,6 +341,26 @@ event that does not match the plan, `502` when Stet could not be reached. An
|
|
|
277
341
|
event outside the plan fails loudly rather than being dropped quietly, so a
|
|
278
342
|
typo surfaces the first time it runs.
|
|
279
343
|
|
|
344
|
+
### Keeping development out of your analytics
|
|
345
|
+
|
|
346
|
+
The same organization key generates your content client, so on a developer's
|
|
347
|
+
machine it is present and real, and every page they load would otherwise land
|
|
348
|
+
in the project your dashboards read. `enabled` is the switch, kept separate
|
|
349
|
+
from the key for exactly that reason:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
const handler = createAnalyticsHandler(plan, {
|
|
353
|
+
enabled: process.env.ANALYTICS_ENABLED !== 'false',
|
|
354
|
+
});
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Test for the off value rather than the on one, so an environment that never
|
|
358
|
+
sets the variable records instead of silently dropping. A disabled handler
|
|
359
|
+
answers `200 { accepted: 0 }` and still checks each batch against the plan, so
|
|
360
|
+
a typo'd event is still a `400` while you are working on it. Leave it on
|
|
361
|
+
locally, point `STET_ORIGIN` at a Stet of your own, and your dashboard fills up
|
|
362
|
+
with your own traffic instead.
|
|
363
|
+
|
|
280
364
|
## Development
|
|
281
365
|
|
|
282
366
|
```bash
|
|
@@ -285,4 +369,9 @@ pnpm tc # Type check
|
|
|
285
369
|
pnpm build # vp pack
|
|
286
370
|
```
|
|
287
371
|
|
|
288
|
-
`examples/tanstack`
|
|
372
|
+
[`examples/tanstack`](https://github.com/jamiedavenport/stet/tree/main/examples/tanstack)
|
|
373
|
+
runs this package against a local Stet; see its README.
|
|
374
|
+
|
|
375
|
+
## License
|
|
376
|
+
|
|
377
|
+
Apache-2.0
|
package/dist/client.d.ts
CHANGED
|
@@ -9,12 +9,15 @@ type AnalyticsOptions = {
|
|
|
9
9
|
autoPageviews?: boolean; /** Override fetch, e.g. in tests. */
|
|
10
10
|
fetch?: typeof globalThis.fetch;
|
|
11
11
|
};
|
|
12
|
+
type PageviewOptions = {
|
|
13
|
+
/** Router template for this URL, e.g. `/blog/[slug]`. */route?: string;
|
|
14
|
+
};
|
|
12
15
|
type Analytics<TPlan extends AnalyticsTypes> = {
|
|
13
16
|
/** Record an event from the tracking plan. Never throws and never rejects. */track: <K extends keyof TPlan['$types']['events'] & string>(name: K, ...args: TrackArgs<TPlan['$types']['events'][K]>) => void; /** Record a pageview. Called for you unless `autoPageviews` is off. */
|
|
14
|
-
pageview: (url?: string) => void; /** Merge props into the context sent with every later batch. */
|
|
17
|
+
pageview: (url?: string, options?: PageviewOptions) => void; /** Merge props into the context sent with every later batch. */
|
|
15
18
|
setContext: (context: Record<string, unknown>) => void; /** Send anything queued now. Resolves once the request settles. */
|
|
16
19
|
flush: () => Promise<void>;
|
|
17
20
|
};
|
|
18
21
|
declare function createAnalytics<TPlan extends AnalyticsTypes>(options: AnalyticsOptions): Analytics<TPlan>;
|
|
19
22
|
//#endregion
|
|
20
|
-
export { Analytics, AnalyticsOptions, type AnalyticsTypes, createAnalytics };
|
|
23
|
+
export { Analytics, AnalyticsOptions, type AnalyticsTypes, PageviewOptions, createAnalytics };
|
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { o as PAGEVIEW, r as isValidRoute } from "./wire-CZwVGiU5.js";
|
|
2
2
|
//#region src/client.ts
|
|
3
3
|
/**
|
|
4
4
|
* Browser client. Talks only to the route you mounted in your own app with
|
|
@@ -68,17 +68,19 @@ function createAnalytics(options) {
|
|
|
68
68
|
...envelope()
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
|
-
function pageview(url) {
|
|
71
|
+
function pageview(url, pageviewOptions) {
|
|
72
72
|
const envelopeUrl = url ?? (isBrowser ? window.location.href : void 0);
|
|
73
73
|
if (envelopeUrl !== void 0 && envelopeUrl === lastPageviewUrl) return;
|
|
74
74
|
lastPageviewUrl = envelopeUrl;
|
|
75
|
-
|
|
75
|
+
const event = {
|
|
76
76
|
name: PAGEVIEW,
|
|
77
77
|
props: {},
|
|
78
78
|
timestamp: Date.now(),
|
|
79
79
|
...envelope(),
|
|
80
80
|
...envelopeUrl === void 0 ? {} : { url: envelopeUrl }
|
|
81
|
-
}
|
|
81
|
+
};
|
|
82
|
+
if (isValidRoute(pageviewOptions?.route)) event.route = pageviewOptions.route;
|
|
83
|
+
enqueue(event);
|
|
82
84
|
}
|
|
83
85
|
if (isBrowser) {
|
|
84
86
|
window.addEventListener("pagehide", () => void flush());
|
package/dist/index.d.ts
CHANGED
|
@@ -43,7 +43,8 @@ type WireEvent = {
|
|
|
43
43
|
name: string;
|
|
44
44
|
props: Record<string, unknown>; /** Epoch milliseconds, stamped in the browser when the event happened. */
|
|
45
45
|
timestamp: number;
|
|
46
|
-
url?: string;
|
|
46
|
+
url?: string; /** Router template for the URL, e.g. `/blog/[slug]`. */
|
|
47
|
+
route?: string;
|
|
47
48
|
referrer?: string;
|
|
48
49
|
};
|
|
49
50
|
/** The body the browser client POSTs to your route. */
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import {
|
|
1
|
+
import { a as BUILT_IN_EVENTS, c as flattenEvents, i as parseClientBatch, l as isEventDefinition, n as MAX_BATCH_EVENTS, o as PAGEVIEW, s as event, t as DEFAULT_ORIGIN, u as resolveEvent } from "./wire-CZwVGiU5.js";
|
|
2
|
+
import { n as formatIssues, r as validateEvent, t as defineAnalytics } from "./plan-C6WUN0RK.js";
|
|
3
3
|
import { syncTrackingPlan } from "./sync.js";
|
|
4
4
|
export { BUILT_IN_EVENTS, DEFAULT_ORIGIN, MAX_BATCH_EVENTS, PAGEVIEW, defineAnalytics, event, flattenEvents, formatIssues, isEventDefinition, parseClientBatch, resolveEvent, syncTrackingPlan, validateEvent };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { a as BUILT_IN_EVENTS, u as resolveEvent } from "./wire-CZwVGiU5.js";
|
|
2
|
+
//#region src/plan.ts
|
|
3
|
+
/**
|
|
4
|
+
* Declares the tracking plan, conventionally in `stet.config.ts`:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* export default defineAnalytics({
|
|
8
|
+
* events: {
|
|
9
|
+
* signup: event({ plan: z.enum(['free', 'paid']) }),
|
|
10
|
+
* checkout: { completed: event({ total: z.number() }) },
|
|
11
|
+
* },
|
|
12
|
+
* });
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
function defineAnalytics(config) {
|
|
16
|
+
return {
|
|
17
|
+
...config,
|
|
18
|
+
$types: void 0
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Validates an event's props against the plan. Declared props are checked
|
|
23
|
+
* with their own schema; undeclared props pass through, so adding a prop in
|
|
24
|
+
* the browser never blocks on a deploy of the plan.
|
|
25
|
+
*/
|
|
26
|
+
async function validateEvent(events, name, props) {
|
|
27
|
+
if (BUILT_IN_EVENTS.has(name)) return {
|
|
28
|
+
ok: true,
|
|
29
|
+
props: props ?? {}
|
|
30
|
+
};
|
|
31
|
+
const definition = resolveEvent(events, name);
|
|
32
|
+
if (definition === void 0) return {
|
|
33
|
+
ok: false,
|
|
34
|
+
issues: [{ message: `unknown event "${name}"` }]
|
|
35
|
+
};
|
|
36
|
+
const input = props ?? {};
|
|
37
|
+
const output = { ...input };
|
|
38
|
+
const issues = [];
|
|
39
|
+
for (const [key, schema] of Object.entries(definition.props)) {
|
|
40
|
+
let result = schema["~standard"].validate(input[key]);
|
|
41
|
+
if (result instanceof Promise) result = await result;
|
|
42
|
+
if (result.issues) issues.push(...result.issues.map((issue) => ({
|
|
43
|
+
...issue,
|
|
44
|
+
message: `${key}: ${issue.message}`
|
|
45
|
+
})));
|
|
46
|
+
else output[key] = result.value;
|
|
47
|
+
}
|
|
48
|
+
if (issues.length > 0) return {
|
|
49
|
+
ok: false,
|
|
50
|
+
issues
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
props: output
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function formatIssues(issues) {
|
|
58
|
+
return issues.map((issue) => issue.message).join("; ");
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { formatIssues as n, validateEvent as r, defineAnalytics as t };
|
package/dist/server.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ type AnalyticsHandlerOptions = {
|
|
|
11
11
|
context?: Record<string, unknown> | ((request: Request) => Record<string, unknown> | Promise<Record<string, unknown>>); /** Stet deployment. Defaults to the plan's, then `STET_ORIGIN`, then the cloud. */
|
|
12
12
|
origin?: string; /** Organization API key. Defaults to the plan's, then `STET_API_KEY`. */
|
|
13
13
|
apiKey?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Whether to forward batches at all. Defaults to whether a key resolved, so
|
|
16
|
+
* an unconfigured deployment stays quiet rather than erroring. Set it
|
|
17
|
+
* explicitly to keep a developer's own traffic out of a project whose key is
|
|
18
|
+
* present for another reason, such as generating the content client.
|
|
19
|
+
*/
|
|
20
|
+
enabled?: boolean;
|
|
14
21
|
/**
|
|
15
22
|
* Salt mixed into the visitor digest. Defaults to the request's host, which
|
|
16
23
|
* keeps visitor ids from being comparable across sites you run.
|
package/dist/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { i as parseClientBatch } from "./wire-CZwVGiU5.js";
|
|
2
|
+
import { n as formatIssues, r as validateEvent } from "./plan-C6WUN0RK.js";
|
|
2
3
|
import { resolveStetConfig } from "@stetcms/config";
|
|
3
4
|
//#region src/metadata.ts
|
|
4
5
|
const BOT_PATTERN = /bot|crawl|spider|slurp|headless|lighthouse|pingdom|preview|monitor|curl|wget|python-requests|axios|node-fetch/i;
|
|
@@ -150,6 +151,7 @@ function createAnalyticsHandler(plan, options = {}) {
|
|
|
150
151
|
origin: options.origin,
|
|
151
152
|
apiKey: options.apiKey
|
|
152
153
|
});
|
|
154
|
+
if (!(options.enabled ?? apiKey !== void 0)) return json({ accepted: 0 });
|
|
153
155
|
if (apiKey === void 0) {
|
|
154
156
|
onError(/* @__PURE__ */ new Error("No API key. Set STET_API_KEY or pass apiKey."));
|
|
155
157
|
return json({ error: "analytics is not configured" }, 500);
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { DEFAULT_ORIGIN as DEFAULT_ORIGIN$1 } from "@stetcms/config";
|
|
2
|
+
//#region src/events.ts
|
|
3
|
+
/** The pageview every browser client sends. */
|
|
4
|
+
const PAGEVIEW = "$pageview";
|
|
5
|
+
/**
|
|
6
|
+
* Events the client sends for itself. `$`-prefixed so a tracking plan can
|
|
7
|
+
* never collide with one; their context (url, referrer) rides the envelope
|
|
8
|
+
* rather than the props, so they carry no schema of their own.
|
|
9
|
+
*/
|
|
10
|
+
const BUILT_IN_EVENTS = /* @__PURE__ */ new Set([PAGEVIEW]);
|
|
11
|
+
/**
|
|
12
|
+
* Declares an event and the props it carries. Props are any Standard Schema
|
|
13
|
+
* validator, so the schema library is yours to pick (Zod, Valibot, ArkType).
|
|
14
|
+
* Events group by nesting, and nested events track under dot-joined names:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* events: {
|
|
18
|
+
* signup: event({ plan: z.enum(['free', 'paid']) }),
|
|
19
|
+
* checkout: { started: event(), completed: event({ total: z.number() }) },
|
|
20
|
+
* }
|
|
21
|
+
* // → 'signup', 'checkout.started', 'checkout.completed'
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
function event(props) {
|
|
25
|
+
return {
|
|
26
|
+
$event: true,
|
|
27
|
+
props: props ?? {}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function isEventDefinition(value) {
|
|
31
|
+
return typeof value === "object" && value !== null && value.$event === true;
|
|
32
|
+
}
|
|
33
|
+
/** Walks a dot-joined name (`checkout.completed`) to its definition. */
|
|
34
|
+
function resolveEvent(events, name) {
|
|
35
|
+
let node = events;
|
|
36
|
+
for (const part of name.split(".")) {
|
|
37
|
+
if (isEventDefinition(node)) return;
|
|
38
|
+
const next = node[part];
|
|
39
|
+
if (next === void 0) return;
|
|
40
|
+
node = next;
|
|
41
|
+
}
|
|
42
|
+
return isEventDefinition(node) ? node : void 0;
|
|
43
|
+
}
|
|
44
|
+
/** Flattens a nested plan to `[{ name: 'checkout.completed', props: ['total'] }]`. */
|
|
45
|
+
function flattenEvents(events, prefix = "") {
|
|
46
|
+
const flat = [];
|
|
47
|
+
for (const [key, value] of Object.entries(events)) {
|
|
48
|
+
const name = prefix === "" ? key : `${prefix}.${key}`;
|
|
49
|
+
if (isEventDefinition(value)) flat.push({
|
|
50
|
+
name,
|
|
51
|
+
props: Object.keys(value.props)
|
|
52
|
+
});
|
|
53
|
+
else flat.push(...flattenEvents(value, name));
|
|
54
|
+
}
|
|
55
|
+
return flat;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/wire.ts
|
|
59
|
+
/** Events per batch. Anything larger is a client bug or an abusive caller. */
|
|
60
|
+
const MAX_BATCH_EVENTS = 100;
|
|
61
|
+
const MAX_NAME_LENGTH = 120;
|
|
62
|
+
const MAX_ROUTE_LENGTH = 500;
|
|
63
|
+
function isValidRoute(route) {
|
|
64
|
+
return typeof route === "string" && route.length > 0 && route.length <= MAX_ROUTE_LENGTH;
|
|
65
|
+
}
|
|
66
|
+
function isRecord(value) {
|
|
67
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
68
|
+
}
|
|
69
|
+
function parseEvent(value, index) {
|
|
70
|
+
if (!isRecord(value)) return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: `events[${index}] is not an object`
|
|
73
|
+
};
|
|
74
|
+
const { name, props, timestamp, url, route, referrer } = value;
|
|
75
|
+
if (typeof name !== "string" || name === "" || name.length > MAX_NAME_LENGTH) return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: `events[${index}].name must be a name of 1 to ${MAX_NAME_LENGTH} characters`
|
|
78
|
+
};
|
|
79
|
+
if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp < 0) return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: `events[${index}].timestamp must be epoch milliseconds`
|
|
82
|
+
};
|
|
83
|
+
if (props !== void 0 && !isRecord(props)) return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: `events[${index}].props must be an object`
|
|
86
|
+
};
|
|
87
|
+
if (url !== void 0 && typeof url !== "string") return {
|
|
88
|
+
ok: false,
|
|
89
|
+
error: `events[${index}].url must be a string`
|
|
90
|
+
};
|
|
91
|
+
if (route !== void 0 && !isValidRoute(route)) return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: `events[${index}].route must be 1 to ${MAX_ROUTE_LENGTH} characters`
|
|
94
|
+
};
|
|
95
|
+
if (referrer !== void 0 && typeof referrer !== "string") return {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: `events[${index}].referrer must be a string`
|
|
98
|
+
};
|
|
99
|
+
const event = {
|
|
100
|
+
name,
|
|
101
|
+
props: isRecord(props) ? props : {},
|
|
102
|
+
timestamp,
|
|
103
|
+
...typeof url === "string" ? { url } : {},
|
|
104
|
+
...typeof referrer === "string" ? { referrer } : {}
|
|
105
|
+
};
|
|
106
|
+
if (typeof route === "string") event.route = route;
|
|
107
|
+
return {
|
|
108
|
+
ok: true,
|
|
109
|
+
value: event
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Validates a batch posted by a browser. The body is untrusted input from the
|
|
114
|
+
* open internet, so this checks shape by hand rather than trusting a cast;
|
|
115
|
+
* props are validated against the tracking plan separately.
|
|
116
|
+
*/
|
|
117
|
+
function parseClientBatch(body) {
|
|
118
|
+
if (!isRecord(body)) return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: "body must be an object"
|
|
121
|
+
};
|
|
122
|
+
if (!Array.isArray(body.events) || body.events.length === 0) return {
|
|
123
|
+
ok: false,
|
|
124
|
+
error: "events must be a non-empty array"
|
|
125
|
+
};
|
|
126
|
+
if (body.events.length > 100) return {
|
|
127
|
+
ok: false,
|
|
128
|
+
error: `events must hold at most 100 entries`
|
|
129
|
+
};
|
|
130
|
+
if (body.context !== void 0 && !isRecord(body.context)) return {
|
|
131
|
+
ok: false,
|
|
132
|
+
error: "context must be an object"
|
|
133
|
+
};
|
|
134
|
+
const events = [];
|
|
135
|
+
for (const [index, raw] of body.events.entries()) {
|
|
136
|
+
const parsed = parseEvent(raw, index);
|
|
137
|
+
if (!parsed.ok) return parsed;
|
|
138
|
+
events.push(parsed.value);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
value: {
|
|
143
|
+
context: isRecord(body.context) ? body.context : {},
|
|
144
|
+
events
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
export { BUILT_IN_EVENTS as a, flattenEvents as c, parseClientBatch as i, isEventDefinition as l, MAX_BATCH_EVENTS as n, PAGEVIEW as o, isValidRoute as r, event as s, DEFAULT_ORIGIN$1 as t, resolveEvent as u };
|
package/package.json
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stetcms/analytics",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Cookieless, type-safe product analytics routed through your own backend. Part of Stet, the CMS for marketing and engineering.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"analytics",
|
|
7
|
+
"cms",
|
|
7
8
|
"cookieless",
|
|
9
|
+
"first-party",
|
|
10
|
+
"headless-cms",
|
|
8
11
|
"pageviews",
|
|
12
|
+
"privacy",
|
|
13
|
+
"product-analytics",
|
|
9
14
|
"stet",
|
|
10
|
-
"
|
|
15
|
+
"stetcms",
|
|
16
|
+
"tracking",
|
|
17
|
+
"typescript"
|
|
11
18
|
],
|
|
12
|
-
"homepage": "https://
|
|
19
|
+
"homepage": "https://docs.stetcms.com/reference/analytics",
|
|
13
20
|
"bugs": "https://github.com/jamiedavenport/stet/issues",
|
|
14
21
|
"license": "Apache-2.0",
|
|
15
22
|
"author": "Jamie Davenport (https://jxd.dev)",
|
|
@@ -22,6 +29,7 @@
|
|
|
22
29
|
"dist"
|
|
23
30
|
],
|
|
24
31
|
"type": "module",
|
|
32
|
+
"sideEffects": false,
|
|
25
33
|
"exports": {
|
|
26
34
|
".": {
|
|
27
35
|
"types": "./dist/index.d.ts",
|
|
@@ -45,7 +53,7 @@
|
|
|
45
53
|
},
|
|
46
54
|
"dependencies": {
|
|
47
55
|
"@standard-schema/spec": "^1.1.0",
|
|
48
|
-
"@stetcms/config": "0.1.
|
|
56
|
+
"@stetcms/config": "0.1.1"
|
|
49
57
|
},
|
|
50
58
|
"devDependencies": {
|
|
51
59
|
"publint": "^0.3.21",
|
package/dist/events-DnYdjoR6.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
//#region src/events.ts
|
|
2
|
-
/** The pageview every browser client sends. */
|
|
3
|
-
const PAGEVIEW = "$pageview";
|
|
4
|
-
/**
|
|
5
|
-
* Events the client sends for itself. `$`-prefixed so a tracking plan can
|
|
6
|
-
* never collide with one; their context (url, referrer) rides the envelope
|
|
7
|
-
* rather than the props, so they carry no schema of their own.
|
|
8
|
-
*/
|
|
9
|
-
const BUILT_IN_EVENTS = /* @__PURE__ */ new Set([PAGEVIEW]);
|
|
10
|
-
/**
|
|
11
|
-
* Declares an event and the props it carries. Props are any Standard Schema
|
|
12
|
-
* validator, so the schema library is yours to pick (Zod, Valibot, ArkType).
|
|
13
|
-
* Events group by nesting, and nested events track under dot-joined names:
|
|
14
|
-
*
|
|
15
|
-
* ```ts
|
|
16
|
-
* events: {
|
|
17
|
-
* signup: event({ plan: z.enum(['free', 'paid']) }),
|
|
18
|
-
* checkout: { started: event(), completed: event({ total: z.number() }) },
|
|
19
|
-
* }
|
|
20
|
-
* // → 'signup', 'checkout.started', 'checkout.completed'
|
|
21
|
-
* ```
|
|
22
|
-
*/
|
|
23
|
-
function event(props) {
|
|
24
|
-
return {
|
|
25
|
-
$event: true,
|
|
26
|
-
props: props ?? {}
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
function isEventDefinition(value) {
|
|
30
|
-
return typeof value === "object" && value !== null && value.$event === true;
|
|
31
|
-
}
|
|
32
|
-
/** Walks a dot-joined name (`checkout.completed`) to its definition. */
|
|
33
|
-
function resolveEvent(events, name) {
|
|
34
|
-
let node = events;
|
|
35
|
-
for (const part of name.split(".")) {
|
|
36
|
-
if (isEventDefinition(node)) return;
|
|
37
|
-
const next = node[part];
|
|
38
|
-
if (next === void 0) return;
|
|
39
|
-
node = next;
|
|
40
|
-
}
|
|
41
|
-
return isEventDefinition(node) ? node : void 0;
|
|
42
|
-
}
|
|
43
|
-
/** Flattens a nested plan to `[{ name: 'checkout.completed', props: ['total'] }]`. */
|
|
44
|
-
function flattenEvents(events, prefix = "") {
|
|
45
|
-
const flat = [];
|
|
46
|
-
for (const [key, value] of Object.entries(events)) {
|
|
47
|
-
const name = prefix === "" ? key : `${prefix}.${key}`;
|
|
48
|
-
if (isEventDefinition(value)) flat.push({
|
|
49
|
-
name,
|
|
50
|
-
props: Object.keys(value.props)
|
|
51
|
-
});
|
|
52
|
-
else flat.push(...flattenEvents(value, name));
|
|
53
|
-
}
|
|
54
|
-
return flat;
|
|
55
|
-
}
|
|
56
|
-
//#endregion
|
|
57
|
-
export { isEventDefinition as a, flattenEvents as i, PAGEVIEW as n, resolveEvent as o, event as r, BUILT_IN_EVENTS as t };
|
package/dist/wire-J3xnrDYj.js
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import { o as resolveEvent, t as BUILT_IN_EVENTS } from "./events-DnYdjoR6.js";
|
|
2
|
-
import { DEFAULT_ORIGIN as DEFAULT_ORIGIN$1 } from "@stetcms/config";
|
|
3
|
-
//#region src/plan.ts
|
|
4
|
-
/**
|
|
5
|
-
* Declares the tracking plan, conventionally in `stet.config.ts`:
|
|
6
|
-
*
|
|
7
|
-
* ```ts
|
|
8
|
-
* export default defineAnalytics({
|
|
9
|
-
* events: {
|
|
10
|
-
* signup: event({ plan: z.enum(['free', 'paid']) }),
|
|
11
|
-
* checkout: { completed: event({ total: z.number() }) },
|
|
12
|
-
* },
|
|
13
|
-
* });
|
|
14
|
-
* ```
|
|
15
|
-
*/
|
|
16
|
-
function defineAnalytics(config) {
|
|
17
|
-
return {
|
|
18
|
-
...config,
|
|
19
|
-
$types: void 0
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Validates an event's props against the plan. Declared props are checked
|
|
24
|
-
* with their own schema; undeclared props pass through, so adding a prop in
|
|
25
|
-
* the browser never blocks on a deploy of the plan.
|
|
26
|
-
*/
|
|
27
|
-
async function validateEvent(events, name, props) {
|
|
28
|
-
if (BUILT_IN_EVENTS.has(name)) return {
|
|
29
|
-
ok: true,
|
|
30
|
-
props: props ?? {}
|
|
31
|
-
};
|
|
32
|
-
const definition = resolveEvent(events, name);
|
|
33
|
-
if (definition === void 0) return {
|
|
34
|
-
ok: false,
|
|
35
|
-
issues: [{ message: `unknown event "${name}"` }]
|
|
36
|
-
};
|
|
37
|
-
const input = props ?? {};
|
|
38
|
-
const output = { ...input };
|
|
39
|
-
const issues = [];
|
|
40
|
-
for (const [key, schema] of Object.entries(definition.props)) {
|
|
41
|
-
let result = schema["~standard"].validate(input[key]);
|
|
42
|
-
if (result instanceof Promise) result = await result;
|
|
43
|
-
if (result.issues) issues.push(...result.issues.map((issue) => ({
|
|
44
|
-
...issue,
|
|
45
|
-
message: `${key}: ${issue.message}`
|
|
46
|
-
})));
|
|
47
|
-
else output[key] = result.value;
|
|
48
|
-
}
|
|
49
|
-
if (issues.length > 0) return {
|
|
50
|
-
ok: false,
|
|
51
|
-
issues
|
|
52
|
-
};
|
|
53
|
-
return {
|
|
54
|
-
ok: true,
|
|
55
|
-
props: output
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
function formatIssues(issues) {
|
|
59
|
-
return issues.map((issue) => issue.message).join("; ");
|
|
60
|
-
}
|
|
61
|
-
//#endregion
|
|
62
|
-
//#region src/wire.ts
|
|
63
|
-
/** Events per batch. Anything larger is a client bug or an abusive caller. */
|
|
64
|
-
const MAX_BATCH_EVENTS = 100;
|
|
65
|
-
const MAX_NAME_LENGTH = 120;
|
|
66
|
-
function isRecord(value) {
|
|
67
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
68
|
-
}
|
|
69
|
-
function parseEvent(value, index) {
|
|
70
|
-
if (!isRecord(value)) return {
|
|
71
|
-
ok: false,
|
|
72
|
-
error: `events[${index}] is not an object`
|
|
73
|
-
};
|
|
74
|
-
const { name, props, timestamp, url, referrer } = value;
|
|
75
|
-
if (typeof name !== "string" || name === "" || name.length > MAX_NAME_LENGTH) return {
|
|
76
|
-
ok: false,
|
|
77
|
-
error: `events[${index}].name must be a name of 1 to ${MAX_NAME_LENGTH} characters`
|
|
78
|
-
};
|
|
79
|
-
if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp < 0) return {
|
|
80
|
-
ok: false,
|
|
81
|
-
error: `events[${index}].timestamp must be epoch milliseconds`
|
|
82
|
-
};
|
|
83
|
-
if (props !== void 0 && !isRecord(props)) return {
|
|
84
|
-
ok: false,
|
|
85
|
-
error: `events[${index}].props must be an object`
|
|
86
|
-
};
|
|
87
|
-
if (url !== void 0 && typeof url !== "string") return {
|
|
88
|
-
ok: false,
|
|
89
|
-
error: `events[${index}].url must be a string`
|
|
90
|
-
};
|
|
91
|
-
if (referrer !== void 0 && typeof referrer !== "string") return {
|
|
92
|
-
ok: false,
|
|
93
|
-
error: `events[${index}].referrer must be a string`
|
|
94
|
-
};
|
|
95
|
-
return {
|
|
96
|
-
ok: true,
|
|
97
|
-
value: {
|
|
98
|
-
name,
|
|
99
|
-
props: isRecord(props) ? props : {},
|
|
100
|
-
timestamp,
|
|
101
|
-
...typeof url === "string" ? { url } : {},
|
|
102
|
-
...typeof referrer === "string" ? { referrer } : {}
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* Validates a batch posted by a browser. The body is untrusted input from the
|
|
108
|
-
* open internet, so this checks shape by hand rather than trusting a cast;
|
|
109
|
-
* props are validated against the tracking plan separately.
|
|
110
|
-
*/
|
|
111
|
-
function parseClientBatch(body) {
|
|
112
|
-
if (!isRecord(body)) return {
|
|
113
|
-
ok: false,
|
|
114
|
-
error: "body must be an object"
|
|
115
|
-
};
|
|
116
|
-
if (!Array.isArray(body.events) || body.events.length === 0) return {
|
|
117
|
-
ok: false,
|
|
118
|
-
error: "events must be a non-empty array"
|
|
119
|
-
};
|
|
120
|
-
if (body.events.length > 100) return {
|
|
121
|
-
ok: false,
|
|
122
|
-
error: `events must hold at most 100 entries`
|
|
123
|
-
};
|
|
124
|
-
if (body.context !== void 0 && !isRecord(body.context)) return {
|
|
125
|
-
ok: false,
|
|
126
|
-
error: "context must be an object"
|
|
127
|
-
};
|
|
128
|
-
const events = [];
|
|
129
|
-
for (const [index, raw] of body.events.entries()) {
|
|
130
|
-
const parsed = parseEvent(raw, index);
|
|
131
|
-
if (!parsed.ok) return parsed;
|
|
132
|
-
events.push(parsed.value);
|
|
133
|
-
}
|
|
134
|
-
return {
|
|
135
|
-
ok: true,
|
|
136
|
-
value: {
|
|
137
|
-
context: isRecord(body.context) ? body.context : {},
|
|
138
|
-
events
|
|
139
|
-
}
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
//#endregion
|
|
143
|
-
export { formatIssues as a, defineAnalytics as i, MAX_BATCH_EVENTS as n, validateEvent as o, parseClientBatch as r, DEFAULT_ORIGIN$1 as t };
|