@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 CHANGED
@@ -1,7 +1,13 @@
1
1
  # @stetcms/analytics
2
2
 
3
- Product analytics for apps built on Stet: pageviews and your own typed events,
4
- routed through your backend instead of a third-party endpoint.
3
+ [![CI](https://github.com/jamiedavenport/stet/actions/workflows/ci.yml/badge.svg)](https://github.com/jamiedavenport/stet/actions/workflows/ci.yml)
4
+ [![Docs](https://img.shields.io/badge/docs-stetcms.com-black.svg)](https://docs.stetcms.com/reference/analytics)
5
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./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 `stet` CLI read for everything. Props take any
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
- analytics.pageview(`${window.location.origin}${to.fullPath}`);
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 page loads, so the default is already correct and
191
- there is nothing to add. With view transitions enabled the client script runs
192
- only once, so listen instead — and here a bare call _is_ right, because the
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
- ```ts
196
- document.addEventListener('astro:page-load', () => analytics.pageview());
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 start and at the end of a build, and by
249
- `stet sync`; you rarely call it yourself.
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` runs this package against a local Stet; see its README.
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 { n as PAGEVIEW } from "./events-DnYdjoR6.js";
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
- enqueue({
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 isEventDefinition, i as flattenEvents, n as PAGEVIEW, o as resolveEvent, r as event, t as BUILT_IN_EVENTS } from "./events-DnYdjoR6.js";
2
- import { a as formatIssues, i as defineAnalytics, n as MAX_BATCH_EVENTS, o as validateEvent, r as parseClientBatch, t as DEFAULT_ORIGIN } from "./wire-J3xnrDYj.js";
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 { a as formatIssues, o as validateEvent, r as parseClientBatch } from "./wire-J3xnrDYj.js";
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.1.0",
4
- "description": "Type-safe, cookieless product analytics for Stet, routed through your own backend.",
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
- "tracking"
15
+ "stetcms",
16
+ "tracking",
17
+ "typescript"
11
18
  ],
12
- "homepage": "https://github.com/jamiedavenport/stet/tree/main/published/analytics#readme",
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.0"
56
+ "@stetcms/config": "0.1.1"
49
57
  },
50
58
  "devDependencies": {
51
59
  "publint": "^0.3.21",
@@ -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 };
@@ -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 };