diff --git a/.changeset/analytics-solid2-redesign.md b/.changeset/analytics-solid2-redesign.md new file mode 100644 index 000000000..6d50e8470 --- /dev/null +++ b/.changeset/analytics-solid2-redesign.md @@ -0,0 +1,40 @@ +--- +"@solid-primitives/analytics": major +--- + +Redesign for Solid.js v2.0 (beta.14) with a queue-based plugin pipeline + +## Breaking Changes + +**Peer dependencies**: `solid-js@^2.0.0-beta.14` and `@solidjs/web@^2.0.0-beta.14` are now required. + +The previous `createAnalytics(handlers)` default export and `EventType` / `TrackHandler` types have been replaced with a richer API: + +- **`makeAnalytics(plugins, options?)`** — non-reactive base primitive returning `[controls, cleanup]` +- **`createAnalytics(plugins, options?)`** — reactive primitive returning controls plus `initialized` and `pendingCount` signals + +### Plugin format + +Plugins follow the [`analytics`](https://www.npmjs.com/package/analytics) npm package interface (`name`, `initialize`, `loaded`, `page`, `track`, `identify`), so any plugin from the [analytics plugin catalogue](https://www.npmjs.com/package/analytics#analytic-plugins) works directly — install it separately and pass it in. + +No first-party plugins are bundled in this package. + +### Event queue + +Events fired before plugins finish initializing are buffered in a bounded FIFO queue and replayed automatically once all plugins report ready. The queue limit and poll interval are configurable via `AnalyticsOptions`. + +### Migration + +```ts +// Before (v0.x) +import createAnalytics, { EventType } from "@solid-primitives/analytics"; +const track = createAnalytics([myHandler]); +track(EventType.Event, { category: "ui", action: "click" }); + +// After (v1.x) — use any plugin from https://www.npmjs.com/package/analytics#analytic-plugins +import { createAnalytics } from "@solid-primitives/analytics"; +import googleAnalytics from "@analytics/google-analytics"; + +const analytics = createAnalytics([googleAnalytics({ measurementId: "G-xxx" })]); +analytics.track("click", { category: "ui" }); +``` diff --git a/packages/analytics/README.md b/packages/analytics/README.md index ca28e3c43..fda5eb4d2 100644 --- a/packages/analytics/README.md +++ b/packages/analytics/README.md @@ -2,7 +2,16 @@ Name: analytics Stage: 0 Package: "@solid-primitives/analytics" -Primitives: createAnalytics +Primitives: + - makeAnalytics + - createAnalytics + - AnalyticsProvider + - useAnalytics + - makeAnalyticsGuard + - createAnalyticsGuard + - createServerPlugin + - createSolidStartRelayPlugin + - createTanStackRelayPlugin Category: Utilities --- @@ -15,18 +24,375 @@ Category: Utilities [![size](https://img.shields.io/badge/size-125_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/analytics) [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -Creates a primitive for analytics management. +Analytics tracking primitives for Solid.js with a queue-based dispatch pipeline, reactive signals, context sharing, navigation guards, and server relay support. -## How to use it +Plugins are **not bundled** — install any plugin from the [analytics plugin catalogue](https://www.npmjs.com/package/analytics#analytic-plugins) (Google Analytics, Segment, Amplitude, Mixpanel, …) and pass it straight in. + +## Installation + +```bash +npm install @solid-primitives/analytics +# or +pnpm add @solid-primitives/analytics +``` + +Install analytics plugins separately: + +```bash +npm install @analytics/google-analytics +``` + +--- + +## Primitives + +The package follows the standard `make*` / `create*` pattern: + +| Primitive | Reactive? | Cleanup | +|---|---|---| +| `makeAnalytics` | No | manual `cleanup()` | +| `createAnalytics` | Yes | auto on owner disposal | +| `AnalyticsProvider` | Yes (via `createAnalytics`) | auto on unmount | +| `makeAnalyticsGuard` | No | manual `cleanup()` | +| `createAnalyticsGuard` | Yes | auto on owner disposal | + +`createAnalytics` is built on top of `makeAnalytics`. `AnalyticsProvider` creates a `createAnalytics` instance and shares it via context. + +--- + +## `makeAnalytics` + +The base primitive. Returns `[controls, cleanup]`. Use this at module level or anywhere Solid's owner tree is unavailable. + +```ts +import { makeAnalytics } from "@solid-primitives/analytics"; +import googleAnalytics from "@analytics/google-analytics"; + +const [analytics, cleanup] = makeAnalytics([ + googleAnalytics({ measurementId: "G-XXXXXXXXXX" }), +]); + +analytics.page({ title: "Home" }); +analytics.track("signup", { plan: "pro" }); +analytics.identify("user-1", { email: "alice@example.com" }); +analytics.use(anotherPlugin); // register a plugin at any time + +cleanup(); +``` + +--- + +## `createAnalytics` + +Reactive wrapper built on `makeAnalytics`. Disposes automatically when the owning scope is destroyed. Exposes a reactive signal: + +- `pendingCount: Accessor` — number of events waiting in the queue (non-zero while plugins are still initializing) + +```tsx +import { createAnalytics } from "@solid-primitives/analytics"; +import googleAnalytics from "@analytics/google-analytics"; +import { Show } from "solid-js"; + +function Root() { + const analytics = createAnalytics([ + googleAnalytics({ measurementId: "G-XXXXXXXXXX" }), + ]); + + return ( + <> + 0}> +

{analytics.pendingCount()} events queued — analytics loading

+
+ + + ); +} +``` + +--- + +## `AnalyticsProvider` / `useAnalytics` + +The easiest way to share analytics across a component tree. Wrap your app once; call `useAnalytics()` in any descendant. + +```tsx +import { AnalyticsProvider, useAnalytics } from "@solid-primitives/analytics"; +import googleAnalytics from "@analytics/google-analytics"; + +// At the root + + + + +// Anywhere inside the tree +function BuyButton() { + const analytics = useAnalytics(); + return ( + + ); +} +``` + +`AnalyticsProvider` creates and owns the `createAnalytics` instance, disposing automatically when the provider unmounts. `useAnalytics()` throws `ContextNotFoundError` if called outside a provider. + +--- + +## Navigation guard + +`makeAnalyticsGuard` / `createAnalyticsGuard` pause navigation until all in-flight plugin dispatches complete, preventing events from being lost when users navigate away before async network calls finish. + +Both guards: +- Return an `onBeforeLeave` handler compatible with SolidJS Router's `useBeforeLeave` +- Register a `beforeunload` listener as a best-effort safety net for hard navigations + +### With SolidJS Router + +```tsx +import { useBeforeLeave } from "@solidjs/router"; +import { useAnalytics, createAnalyticsGuard } from "@solid-primitives/analytics"; + +function App() { + const analytics = useAnalytics(); + const { onBeforeLeave } = createAnalyticsGuard(analytics); + useBeforeLeave(onBeforeLeave); +} +``` + +### Without a router ```ts -const [running, start, stop] = createAnalytics(() => console.log('hi'))); -start(); +import { makeAnalytics, makeAnalyticsGuard } from "@solid-primitives/analytics"; + +const [analytics, cleanupAnalytics] = makeAnalytics([...]); +const { onBeforeLeave, cleanup } = makeAnalyticsGuard(analytics); + +// wire onBeforeLeave into your routing solution +cleanup(); // call when tearing down ``` -## Demo +### How it works + +When `onBeforeLeave` fires: +1. Navigation is paused via `event.preventDefault()` +2. `analytics.drain()` waits for all in-flight plugin dispatches to settle +3. Navigation resumes via `event.retry(true)` + +--- + +## Server relay + +Route analytics events through your own server function instead of calling providers directly from the browser. This keeps API keys off the client and lets you enrich events with server-side context (session data, IP-based geo, etc.) before forwarding to the provider. + +```text +browser → createServerPlugin → your server function → GA / Mixpanel / Segment +``` + +### `createServerPlugin` + +```ts +import { createServerPlugin } from "@solid-primitives/analytics/relay"; +``` + +Creates a client-side plugin that forwards every event to a server function. The server function receives the full `AnyPayload` (including the original `rid` and `ts`) and is responsible for calling the actual provider. + +```ts +// client +import { createAnalytics } from "@solid-primitives/analytics"; +import { createServerPlugin } from "@solid-primitives/analytics/relay"; +import { relayEvent } from "./analytics.server"; + +const analytics = createAnalytics([ + createServerPlugin(relayEvent, { events: ["track", "identify"] }), +]); + +analytics.track("purchase", { orderId: "123" }); // → server → GA +``` + +**Options:** + +| Option | Type | Description | +|---|---|---| +| `name` | `string` | Plugin name. Default: `"server"` | +| `events` | `Array<"page" \| "track" \| "identify">` | Limit which event types are relayed. Omit to relay all. | + +--- + +### SolidStart -You may view a working example here: https://codesandbox.io/s/solid-create-analytics?file=/src/index.tsx +```ts +import { createSolidStartRelayPlugin } from "@solid-primitives/analytics/relay/solidstart"; +``` + +Because SolidStart requires `"use server"` as a literal in your source code, define the action yourself and pass it to `createSolidStartRelayPlugin`: + +```ts +// analytics.server.ts +import { action } from "@solidjs/router"; +import googleAnalytics from "@analytics/google-analytics"; +import type { AnyPayload } from "@solid-primitives/analytics"; + +const ga = googleAnalytics({ measurementId: import.meta.env.VITE_GA_ID }); + +export const relayEvent = action(async (payload: AnyPayload) => { + "use server"; + await ga.track?.({ payload, config: {}, abort: () => {} }); +}); +``` + +```ts +// app.tsx +import { createAnalytics } from "@solid-primitives/analytics"; +import { createSolidStartRelayPlugin } from "@solid-primitives/analytics/relay/solidstart"; +import { relayEvent } from "./analytics.server"; + +const analytics = createAnalytics([ + createSolidStartRelayPlugin(relayEvent, { events: ["track", "identify"] }), +]); +``` + +--- + +### TanStack Start + +```ts +import { createTanStackRelayPlugin } from "@solid-primitives/analytics/relay/tanstack"; +``` + +`createTanStackRelayPlugin` wraps your TanStack Start server function and handles the `{ data }` call-site signature automatically: + +```ts +// analytics.server.ts +import { createServerFn } from "@tanstack/start"; +import googleAnalytics from "@analytics/google-analytics"; +import type { AnyPayload } from "@solid-primitives/analytics"; + +const ga = googleAnalytics({ measurementId: process.env.GA_ID }); + +export const relayEvent = createServerFn({ method: "POST" }) + .validator((d: unknown) => d as AnyPayload) + .handler(async ({ data: payload }) => { + await ga.track?.({ payload, config: {}, abort: () => {} }); + }); +``` + +```ts +// app.tsx +import { createAnalytics } from "@solid-primitives/analytics"; +import { createTanStackRelayPlugin } from "@solid-primitives/analytics/relay/tanstack"; +import { relayEvent } from "./analytics.server"; + +const analytics = createAnalytics([ + createTanStackRelayPlugin(relayEvent, { events: ["track", "identify"] }), +]); +``` + +--- + +## `drain()` + +All controls expose `drain(): Promise`, which resolves once every currently in-flight plugin call has settled. Useful for imperative navigation: + +```ts +await analytics.drain(); +router.navigate("/checkout"); +``` + +> Events still in the queue (waiting for a plugin to initialize) are not awaited by `drain()`. In practice, plugins are ready long before a user navigates. + +--- + +## Controls reference + +`makeAnalytics`, `createAnalytics`, and `useAnalytics()` all return the same control surface: + +| Method | Description | +|---|---| +| `page(properties?)` | Track a page view. Auto-fills `path`, `url`, `title`, and `referrer` from `window.location` in the browser. | +| `track(event, properties?)` | Track a named event with optional properties. | +| `identify(userId, traits?)` | Associate subsequent events with a user identity. | +| `use(plugin)` | Register an additional plugin at any time. | +| `reset()` | Stop the poll timer and discard all queued events. | +| `drain()` | `Promise` — resolves when all in-flight dispatches have settled. | + +`createAnalytics` and `useAnalytics()` additionally expose: + +| Property | Type | Description | +|---|---|---| +| `pendingCount` | `Accessor` | Events waiting in the queue; non-zero while plugins are still initializing | + +--- + +## How the queue works + +Events fire immediately to all ready plugins. When one or more plugins are still initializing (async script load, consent check, etc.), events are held in a bounded FIFO queue and replayed in order once all plugins become ready. A poll timer (default: 500 ms) handles `loaded()` checks for external scripts. + +Dispatch is **sequential** through the plugin list. A plugin can call `abort()` to prevent subsequent plugins from receiving that event. + +--- + +## Plugin interface + +`AnalyticsPlugin` matches the [analytics npm package plugin interface](https://getanalytics.io/plugins/writing-plugins/), so any plugin from the [catalogue](https://www.npmjs.com/package/analytics#analytic-plugins) is compatible without modification. + +```ts +import type { AnalyticsPlugin } from "@solid-primitives/analytics"; + +const myPlugin: AnalyticsPlugin = { + name: "my-service", + config: { apiKey: "abc123" }, + + // optional async setup (e.g. inject a script tag) + initialize: async ({ config }) => { + await loadScript(`https://cdn.example.com/sdk.js?key=${config.apiKey}`); + }, + + // polled until true before queued events are flushed + loaded: () => typeof window.myService !== "undefined", + + page: ({ payload }) => { window.myService.page(payload.properties); }, + track: ({ payload }) => { window.myService.track(payload.event, payload.properties); }, + identify: ({ payload }) => { window.myService.identify(payload.userId, payload.traits); }, +}; +``` + +All event payloads carry a `meta` object with a unique request ID (`rid`) and a Unix timestamp (`ts`). + +--- + +## Options + +```ts +type AnalyticsOptions = { + /** Maximum events to hold in the queue before discarding. Default: 100 */ + queueLimit?: number; + /** Milliseconds between plugin-readiness polls. Default: 500 */ + retryInterval?: number; + /** + * When set, switches to batch mode: events accumulate and flush on this + * interval (ms) rather than immediately. + */ + drainInterval?: number; + /** + * Maximum events dispatched per drain cycle. Only applies when + * `drainInterval` is set. Remaining events stay queued for the next cycle. + */ + drainSize?: number; +}; +``` + +--- + +## SSR + +Both `makeAnalytics` and `createAnalytics` are isomorphic. On the server, `page()` omits browser-only defaults (`url`, `referrer`, `title`) since `window` is unavailable, but all other behaviour — plugin dispatch, queuing, `drain()` — works identically. + +`makeAnalyticsGuard` / `createAnalyticsGuard` are no-ops on the server. + +--- ## Changelog diff --git a/packages/analytics/package.json b/packages/analytics/package.json index f903c7939..db9378ff1 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -1,7 +1,7 @@ { "name": "@solid-primitives/analytics", - "version": "0.2.1", - "description": "Primitive that makes managing analytics a lot easier.", + "version": "1.0.0", + "description": "Solid primitives for analytics tracking with a plugin-compatible queue-based dispatch pipeline.", "author": "David Di Biase ", "primitive": { "name": "analytics", @@ -23,33 +23,79 @@ "browser": {}, "types": "./dist/index.d.ts", "exports": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./relay": { + "import": { + "types": "./dist/relay.d.ts", + "default": "./dist/relay.js" + } + }, + "./relay/solidstart": { + "import": { + "types": "./dist/relay/solidstart.d.ts", + "default": "./dist/relay/solidstart.js" + } + }, + "./relay/tanstack": { + "import": { + "types": "./dist/relay/tanstack.d.ts", + "default": "./dist/relay/tanstack.js" + } } }, "files": [ "dist" ], "scripts": { - "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts", "vitest": "vitest -c ../../configs/vitest.config.ts", "test": "pnpm run vitest", "test:ssr": "pnpm run vitest --mode ssr" }, + "primitive": { + "name": "analytics", + "stage": 0, + "list": [ + "makeAnalytics", + "createAnalytics", + "AnalyticsProvider", + "useAnalytics", + "makeAnalyticsGuard", + "createAnalyticsGuard", + "createServerPlugin" + ], + "category": "Utilities" + }, "keywords": [ "analytics", - "google", - "gtag", + "tracking", "solid", + "solidstart", + "tanstack", "primitives" ], "peerDependencies": { - "solid-js": "^1.6.12" + "@solidjs/web": "^2.0.0-beta.14", + "@tanstack/start": "*", + "solid-js": "^2.0.0-beta.14" + }, + "peerDependenciesMeta": { + "@tanstack/start": { + "optional": true + } + }, + "dependencies": { + "@solid-primitives/event-listener": "workspace:^", + "@solid-primitives/queue": "workspace:^", + "@solid-primitives/utils": "workspace:^" }, - "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.14", + "solid-js": "2.0.0-beta.14" } } diff --git a/packages/analytics/src/analytics.ts b/packages/analytics/src/analytics.ts new file mode 100644 index 000000000..4818be65d --- /dev/null +++ b/packages/analytics/src/analytics.ts @@ -0,0 +1,252 @@ +import { createSignal } from "solid-js"; +import { tryOnCleanup, INTERNAL_OPTIONS, isServer, createIdGenerator } from "@solid-primitives/utils"; +import { makeQueue } from "@solid-primitives/queue"; +import type { + AnyPayload, + AnalyticsPlugin, + AnalyticsOptions, + AnalyticsControls, + ReactiveAnalyticsControls, + PageProperties, + TrackProperties, + IdentifyTraits, + PagePayload, + TrackPayload, + IdentifyPayload, +} from "./types.js"; + + + +const generateId = createIdGenerator(); + +function buildPagePayload(properties: PageProperties): PagePayload { + const defaults: PageProperties = !isServer + ? { path: window.location.pathname, url: window.location.href, title: document.title, referrer: document.referrer } + : {}; + return { + type: "page", + properties: { ...defaults, ...properties }, + meta: { rid: generateId(), ts: Date.now() }, + }; +} + +function buildTrackPayload(event: string, properties: TrackProperties): TrackPayload { + return { type: "track", event, properties, meta: { rid: generateId(), ts: Date.now() } }; +} + +function buildIdentifyPayload(userId: string, traits: IdentifyTraits): IdentifyPayload { + return { type: "identify", userId, traits, meta: { rid: generateId(), ts: Date.now() } }; +} + +/** + * Send a payload to a single plugin. + * Returns `true` if the plugin processed the event, `false` if it aborted. + */ +async function invokePlugin(plugin: AnalyticsPlugin, payload: AnyPayload): Promise { + const config = plugin.config ?? {}; + let aborted = false; + const abort = (): void => { aborted = true; }; + try { + if (payload.type === "page" && plugin.page) { + await plugin.page({ payload, config, abort }); + } else if (payload.type === "track" && plugin.track) { + await plugin.track({ payload, config, abort }); + } else if (payload.type === "identify" && plugin.identify) { + await plugin.identify({ payload, config, abort }); + } + } catch { + // Individual plugin errors never stop the pipeline. + } + return !aborted; +} + +/** + * Send a payload through all ready plugins sequentially. + * A plugin that calls abort() stops subsequent plugins from receiving the event. + */ +async function dispatchSequential(plugins: AnalyticsPlugin[], payload: AnyPayload): Promise { + for (const plugin of plugins) { + if (!await invokePlugin(plugin, payload)) break; + } +} + +/** Minimal queue interface used by buildCore — a subset of Queue. */ +type EventBuffer = { + readonly size: number; + readonly isEmpty: boolean; + add(item: AnyPayload): void; + remove(): AnyPayload | undefined; + clear(): void; +}; + +/** Internal engine — shared by makeAnalytics and createAnalytics. */ +function buildCore(queue: EventBuffer, options: AnalyticsOptions) { + const { queueLimit = 100, retryInterval = 500, drainInterval, drainSize } = options; + const batching = drainInterval != null; + const plugins: AnalyticsPlugin[] = []; + const initialized = new Set(); + const inflight = new Set>(); + let pollTimer: ReturnType | undefined; + let drainTimer: ReturnType | undefined; + + function trackInflight(p: Promise): void { + inflight.add(p); + void p.finally(() => inflight.delete(p)); + } + + function isReady(plugin: AnalyticsPlugin): boolean { + return initialized.has(plugin.name) && (plugin.loaded == null || plugin.loaded()); + } + + function allReady(): boolean { + return plugins.length > 0 && plugins.every(isReady); + } + + function drainBatch(): AnyPayload[] { + const events: AnyPayload[] = []; + const max = batching ? (drainSize ?? Infinity) : Infinity; + let item: AnyPayload | undefined; + while (events.length < max && (item = queue.remove()) !== undefined) { + events.push(item); + } + return events; + } + + async function drainQueue(): Promise { + for (const payload of drainBatch()) { + trackInflight(dispatchSequential(plugins, payload)); + } + await Promise.all([...inflight]); + } + + function stopPoll(): void { + if (pollTimer != null) { + clearInterval(pollTimer); + pollTimer = undefined; + } + } + + function startPoll(): void { + if (pollTimer != null) return; + pollTimer = setInterval(() => { + if (allReady()) { + stopPoll(); + if (!batching) void drainQueue(); + else startDrainTimer(); + } + }, retryInterval); + } + + function stopDrainTimer(): void { + if (drainTimer != null) { + clearInterval(drainTimer); + drainTimer = undefined; + } + } + + function startDrainTimer(): void { + if (!batching || drainTimer != null) return; + drainTimer = setInterval(() => { + if (queue.size > 0) void drainQueue(); + }, drainInterval); + } + + async function initPlugin(plugin: AnalyticsPlugin): Promise { + const config = plugin.config ?? {}; + if (plugin.initialize) await plugin.initialize({ config }); + initialized.add(plugin.name); + if (allReady()) { + stopPoll(); + if (batching) startDrainTimer(); + else await drainQueue(); + } else { + startPoll(); + } + } + + function addPlugin(plugin: AnalyticsPlugin): void { + plugins.push(plugin); + void initPlugin(plugin); + if (!allReady()) startPoll(); + } + + function dispatch(payload: AnyPayload): void { + if (!batching && allReady()) { + trackInflight(dispatchSequential(plugins, payload)); + } else if (queue.size < queueLimit) { + queue.add(payload); + } + } + + async function drain(): Promise { + await Promise.all([...inflight]); + } + + function stop(): void { + stopPoll(); + stopDrainTimer(); + queue.clear(); + } + + return { addPlugin, dispatch, drain, stop }; +} + +function buildControls(core: ReturnType): AnalyticsControls { + return { + page: (properties = {}) => core.dispatch(buildPagePayload(properties)), + track: (event, properties = {}) => core.dispatch(buildTrackPayload(event, properties)), + identify: (userId, traits = {}) => core.dispatch(buildIdentifyPayload(userId, traits)), + use: core.addPlugin, + reset: core.stop, + drain: core.drain, + }; +} + +/** + * Non-reactive analytics primitive. Returns controls and a cleanup function. + * Works on both server and client — plugins without DOM dependencies run identically + * in SSR. Page property defaults (path, url, title, referrer) are omitted on the server. + * + * @param onQueueChange - Optional callback fired after every queue mutation with the + * new size. Used internally by `createAnalytics` to keep a reactive signal in sync. + */ +export function makeAnalytics( + plugins: AnalyticsPlugin[], + options: AnalyticsOptions = {}, + onQueueChange?: (size: number) => void, +): [AnalyticsControls, () => void] { + const q = makeQueue(); + + // When an observer is provided, wrap the plain queue so every mutation also + // fires the callback. Synchronous reads (isEmpty, size) go directly to the + // underlying queue — no batching involved — which is what the drain loop needs. + const queue: EventBuffer = onQueueChange + ? { + get size() { return q.size; }, + get isEmpty() { return q.isEmpty; }, + add(item) { q.add(item); onQueueChange(q.size); }, + remove() { const r = q.remove(); onQueueChange(q.size); return r; }, + clear() { q.clear(); onQueueChange(0); }, + } + : q; + + const core = buildCore(queue, options); + for (const plugin of plugins) core.addPlugin(plugin); + return [buildControls(core), core.stop]; +} + +/** + * Reactive analytics primitive built on top of `makeAnalytics`. + * Integrates with Solid's owner tree — auto-disposes on cleanup. + * Exposes `pendingCount` as a reactive signal driven by `makeAnalytics`'s queue. + */ +export function createAnalytics( + plugins: AnalyticsPlugin[], + options: AnalyticsOptions = {}, +): ReactiveAnalyticsControls { + // ownedWrite: true because dispatch() may be called inside createRoot scope in tests. + const [pendingCount, setPendingCount] = createSignal(0, INTERNAL_OPTIONS); + const [controls, cleanup] = makeAnalytics(plugins, options, setPendingCount); + tryOnCleanup(cleanup); + return { ...controls, pendingCount }; +} diff --git a/packages/analytics/src/context.ts b/packages/analytics/src/context.ts new file mode 100644 index 000000000..17c46b5af --- /dev/null +++ b/packages/analytics/src/context.ts @@ -0,0 +1,49 @@ +import { createComponent, createContext, useContext } from "solid-js"; +import type { Element } from "solid-js"; +import { createAnalytics } from "./analytics.js"; +import type { ReactiveAnalyticsControls, AnalyticsPlugin, AnalyticsOptions } from "./types.js"; + +const AnalyticsCtx = createContext(); + +/** + * Provides an analytics instance to all descendant components. + * Creates its own `createAnalytics` instance and disposes it when unmounted. + * + * ```tsx + * import googleAnalytics from "@analytics/google-analytics"; + * + * + * + * + * ``` + */ +export function AnalyticsProvider(props: { + plugins: AnalyticsPlugin[]; + options?: AnalyticsOptions; + children: Element; +}): Element { + const analytics = createAnalytics(props.plugins, props.options); + return createComponent(AnalyticsCtx, { + value: analytics, + get children() { + return props.children; + }, + }); +} + +/** + * Returns the nearest `AnalyticsProvider`'s controls. + * Throws `ContextNotFoundError` if called outside a provider. + * + * ```ts + * function TrackButton() { + * const analytics = useAnalytics(); + * return ; + * } + * ``` + */ +export const useAnalytics = (): ReactiveAnalyticsControls => { + const controls = useContext(AnalyticsCtx); + if (controls === undefined) throw new Error("useAnalytics: must be called inside "); + return controls; +}; diff --git a/packages/analytics/src/ga.ts b/packages/analytics/src/ga.ts deleted file mode 100644 index ac427808b..000000000 --- a/packages/analytics/src/ga.ts +++ /dev/null @@ -1,48 +0,0 @@ -// @ts-nocheck -import { TrackHandler, EventType } from "./index.js"; - -type GoogleAnalyticsOptions = { - trackingId: string; - cookieDomain?: string; - name?: string; - userId: string; -}; - -export const loadGoogleAnalytics = (options: GoogleAnalyticsOptions) => { - window.ga = - window.ga || - function () { - (ga.q = ga.q || []).push(arguments); - }; - ga.l = +new Date(); - ga("create", options); - const el = document.createElement("script"); - el.async = true; - el.src = "https://www.google-analytics.com/analytics.js"; - document.head.append(el); -}; - -export const trackGoogleAnalytics: TrackHandler = (event, data) => { - switch (event) { - case EventType.Pageview: - ga("send", "pageview", data.location, data.other); - break; - case EventType.Social: - ga("send", { - hitType: "social", - socialNetwork: data.network, - socialAction: data.action, - socialTarget: data.target, - }); - break; - case EventType.Event: - ga("send", "event", { - eventCategory: data.category, - eventAction: data.action, - eventLabel: data.label, - eventValue: data.value, - ...data.other, - }); - break; - } -}; diff --git a/packages/analytics/src/guard.ts b/packages/analytics/src/guard.ts new file mode 100644 index 000000000..b227f6596 --- /dev/null +++ b/packages/analytics/src/guard.ts @@ -0,0 +1,73 @@ +import { isServer } from "@solidjs/web"; +import { makeEventListener, createEventListener } from "@solid-primitives/event-listener"; +import type { AnalyticsControls } from "./types.js"; + +/** Minimal shape required by the guard — accepts both make and create controls. */ +type Drainable = Pick; + +/** + * Minimal shape of SolidJS Router's BeforeLeaveEvent. + * Typed here so the guard compiles without `@solidjs/router` as a hard dependency. + */ +export type BeforeLeaveEvent = { + defaultPrevented: boolean; + preventDefault(): void; + /** Call with `true` to bypass this guard on the retry. */ + retry(force?: boolean): void; +}; + +function buildOnBeforeLeave(analytics: Drainable): (event: BeforeLeaveEvent) => void { + let guarding = false; + return function onBeforeLeave(event: BeforeLeaveEvent): void { + if (guarding || event.defaultPrevented) return; + event.preventDefault(); + guarding = true; + void analytics.drain().finally(() => { + guarding = false; + event.retry(true); + }); + }; +} + +/** + * Non-reactive navigation guard. Call cleanup() when done (e.g. on route teardown). + * + * - Registers a `beforeunload` listener so hard navigations attempt a best-effort drain. + * - Returns `onBeforeLeave` for use with SolidJS Router's `useBeforeLeave`: + * + * ```ts + * import { useBeforeLeave } from "@solidjs/router"; + * const [analytics, cleanup] = makeAnalytics([...]); + * const { onBeforeLeave, cleanup: guardCleanup } = makeAnalyticsGuard(analytics); + * useBeforeLeave(onBeforeLeave); + * ``` + */ +export function makeAnalyticsGuard(analytics: Drainable): { + onBeforeLeave: (event: BeforeLeaveEvent) => void; + cleanup: () => void; +} { + if (isServer) return { onBeforeLeave: () => {}, cleanup: () => {} }; + const onBeforeLeave = buildOnBeforeLeave(analytics); + const cleanup = makeEventListener(window, "beforeunload", () => void analytics.drain()); + return { onBeforeLeave, cleanup }; +} + +/** + * Reactive navigation guard. Auto-removes the `beforeunload` listener when the owner disposes. + * + * ```tsx + * function App() { + * const analytics = useAnalytics(); + * const { onBeforeLeave } = createAnalyticsGuard(analytics); + * useBeforeLeave(onBeforeLeave); + * } + * ``` + */ +export function createAnalyticsGuard(analytics: Drainable): { + onBeforeLeave: (event: BeforeLeaveEvent) => void; +} { + if (isServer) return { onBeforeLeave: () => {} }; + const onBeforeLeave = buildOnBeforeLeave(analytics); + createEventListener(window, "beforeunload", () => void analytics.drain()); + return { onBeforeLeave }; +} diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index 4ba5f4710..a5b697b93 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -1,44 +1,24 @@ -export enum EventType { - Pageview = "page", - Event = "event", - Social = "social", -} -export type TrackEventData = { - category?: string; - action?: string; - label?: string; - value?: string; - location?: string; - other?: object; -}; -export type TrackSocialData = { - network: string; - action: string; - socialtarget: string; -}; -export type TrackPageview = { - location?: string; - other?: object; -}; -export type TrackHandler = ( - type: EventType, - data: TrackEventData | TrackSocialData | TrackPageview, -) => void; +export type { + PageProperties, + TrackProperties, + IdentifyTraits, + EventMeta, + PagePayload, + TrackPayload, + IdentifyPayload, + AnyPayload, + PluginArgs, + AnalyticsPlugin, + AnalyticsOptions, + AnalyticsControls, + ReactiveAnalyticsControls, +} from "./types.js"; -/** - * Creates a method that support with analytics reporting. - * - * @param handlers A list of reporting handlers - * @returns Returns a tracking a single tracking handler - * - */ -const createAnalytics = (handlers: Array): TrackHandler => { - const track: TrackHandler = (type, data) => { - for (const i in handlers) { - handlers[i]!(type, data); - } - }; - return track; -}; +export { makeAnalytics, createAnalytics } from "./analytics.js"; -export default createAnalytics; +export { createServerPlugin } from "./relay.js"; + +export { AnalyticsProvider, useAnalytics } from "./context.js"; + +export { makeAnalyticsGuard, createAnalyticsGuard } from "./guard.js"; +export type { BeforeLeaveEvent } from "./guard.js"; diff --git a/packages/analytics/src/relay.ts b/packages/analytics/src/relay.ts new file mode 100644 index 000000000..4b942ae86 --- /dev/null +++ b/packages/analytics/src/relay.ts @@ -0,0 +1,32 @@ +import type { AnalyticsPlugin, AnyPayload } from "./types.js"; + +/** + * Creates a client-side plugin that forwards every analytics event to a server + * function, which relays it to the actual provider (GA, Mixpanel, Segment, etc.) + * via its server-side SDK or HTTP API. + * + * This keeps API keys off the client and lets you enrich events with server-side + * context (session data, IP geo, etc.) before forwarding. + * + * ```ts + * // client + * const analytics = createAnalytics([ + * createServerPlugin(relayEvent, { events: ["track", "identify"] }), + * ]); + * + * analytics.track("purchase", { orderId: "123" }); // → server → GA + * ``` + */ +export function createServerPlugin( + fn: (payload: AnyPayload) => Promise, + options: { name?: string; events?: Array } = {}, +): AnalyticsPlugin { + const { name = "server", events } = options; + const handles = (type: AnyPayload["type"]) => !events || events.includes(type); + return { + name, + page: handles("page") ? ({ payload }) => fn(payload) as Promise : undefined, + track: handles("track") ? ({ payload }) => fn(payload) as Promise : undefined, + identify: handles("identify") ? ({ payload }) => fn(payload) as Promise : undefined, + }; +} diff --git a/packages/analytics/src/relay/solidstart.ts b/packages/analytics/src/relay/solidstart.ts new file mode 100644 index 000000000..fcdd38258 --- /dev/null +++ b/packages/analytics/src/relay/solidstart.ts @@ -0,0 +1,37 @@ +import type { AnyPayload, AnalyticsPlugin } from "../types.js"; +import { createServerPlugin } from "../relay.js"; + +/** + * Creates an analytics plugin that relays client events to a SolidStart server + * action, which dispatches them to your provider (GA, Mixpanel, etc.) server-side. + * + * Because SolidStart requires `"use server"` as a source literal, define the action + * yourself and pass it here: + * + * ```ts + * // analytics.server.ts + * import { action, reload } from "@solidjs/router"; + * import googleAnalytics from "@analytics/google-analytics"; + * + * export const relayEvent = action(async (payload: AnyPayload) => { + * "use server"; + * const ga = googleAnalytics({ measurementId: import.meta.env.GA_ID }); + * await ga.track?.({ payload, config: {}, abort: () => {} }); + * return reload({ revalidate: [] }); + * }); + * + * // app.tsx + * import { createSolidStartRelayPlugin } from "@solid-primitives/analytics/relay/solidstart"; + * import { relayEvent } from "./analytics.server"; + * + * const analytics = createAnalytics([ + * createSolidStartRelayPlugin(relayEvent, { events: ["track", "identify"] }), + * ]); + * ``` + */ +export function createSolidStartRelayPlugin( + action: (payload: AnyPayload) => Promise, + options?: { name?: string; events?: Array }, +): AnalyticsPlugin { + return createServerPlugin(action, { name: "solidstart", ...options }); +} diff --git a/packages/analytics/src/relay/tanstack.ts b/packages/analytics/src/relay/tanstack.ts new file mode 100644 index 000000000..4ca408f19 --- /dev/null +++ b/packages/analytics/src/relay/tanstack.ts @@ -0,0 +1,37 @@ +import type { AnyPayload, AnalyticsPlugin } from "../types.js"; +import { createServerPlugin } from "../relay.js"; + +/** + * Creates an analytics plugin that relays client events to a TanStack Start server + * function, which dispatches them to your provider (GA, Mixpanel, etc.) server-side. + * + * Pass the already-created server function — `createTanStackRelayPlugin` handles + * the `{ data }` call signature so you don't have to: + * + * ```ts + * // analytics.server.ts + * import { createServerFn } from "@tanstack/solid-start"; + * import googleAnalytics from "@analytics/google-analytics"; + * + * export const relayEvent = createServerFn({ method: "POST" }) + * .inputValidator((d: unknown) => d as AnyPayload) + * .handler(async ({ data: payload }) => { + * const ga = googleAnalytics({ measurementId: process.env.GA_ID }); + * await ga.track?.({ payload, config: {}, abort: () => {} }); + * }); + * + * // app.tsx + * import { createTanStackRelayPlugin } from "@solid-primitives/analytics/relay/tanstack"; + * import { relayEvent } from "./analytics.server"; + * + * const analytics = createAnalytics([ + * createTanStackRelayPlugin(relayEvent, { events: ["track", "identify"] }), + * ]); + * ``` + */ +export function createTanStackRelayPlugin( + fn: (input: { data: AnyPayload }) => Promise, + options?: { name?: string; events?: Array }, +): AnalyticsPlugin { + return createServerPlugin(payload => fn({ data: payload }), { name: "tanstack", ...options }); +} diff --git a/packages/analytics/src/types.ts b/packages/analytics/src/types.ts new file mode 100644 index 000000000..aabfec3ce --- /dev/null +++ b/packages/analytics/src/types.ts @@ -0,0 +1,108 @@ +import type { Accessor } from "solid-js"; + +export type PageProperties = { + path?: string; + url?: string; + title?: string; + referrer?: string; + [key: string]: unknown; +}; + +export type TrackProperties = Record; + +export type IdentifyTraits = Record; + +export type EventMeta = { + /** Unique request identifier for this event. Unique within an analytics instance and with high probability unique across independent instances (separate processes or SSR requests). */ + rid: string; + /** Unix timestamp (ms) when the event was created. */ + ts: number; +}; + +export type PagePayload = { + type: "page"; + properties: PageProperties; + meta: EventMeta; +}; + +export type TrackPayload = { + type: "track"; + event: string; + properties: TrackProperties; + meta: EventMeta; +}; + +export type IdentifyPayload = { + type: "identify"; + userId: string; + traits: IdentifyTraits; + meta: EventMeta; +}; + +export type AnyPayload = PagePayload | TrackPayload | IdentifyPayload; + +export type PluginArgs

= { + payload: P; + /** Plugin-level config merged from plugin.config. */ + config: Record; + /** Stops subsequent plugins from receiving this event. */ + abort: () => void; +}; + +/** + * Analytics plugin interface compatible with the `analytics` npm package. + * Any plugin built for that library can be used directly. + */ +export interface AnalyticsPlugin { + name: string; + /** Static plugin-level configuration passed to each handler. */ + config?: Record; + /** Runs once when the plugin is registered. May be async (e.g. script injection). */ + initialize?: (args: { config: Record }) => void | Promise; + /** Polled to determine whether an async dependency (e.g. a script tag) has loaded. */ + loaded?: () => boolean; + page?: (args: PluginArgs) => void | Promise; + track?: (args: PluginArgs) => void | Promise; + identify?: (args: PluginArgs) => void | Promise; +} + +export type AnalyticsOptions = { + /** Maximum number of events to hold in the queue before discarding. Default: 100. */ + queueLimit?: number; + /** Milliseconds between plugin-readiness checks. Default: 500. */ + retryInterval?: number; + /** + * When set, events are batched and dispatched on this interval (ms) rather than + * immediately. Without this option the current event fires as soon as plugins are ready. + */ + drainInterval?: number; + /** + * Maximum events dispatched per drain cycle. Only applies when `drainInterval` is set. + * Remaining events stay queued until the next cycle. + */ + drainSize?: number; +}; + +export type AnalyticsControls = { + /** Track a page view. Defaults to current window location when called in a browser. */ + page(properties?: PageProperties): void; + /** Track a named event with optional properties. */ + track(event: string, properties?: TrackProperties): void; + /** Associate subsequent events with a user identity. */ + identify(userId: string, traits?: IdentifyTraits): void; + /** Dynamically register an additional plugin. */ + use(plugin: AnalyticsPlugin): void; + /** Stop the internal poll timer and clear any queued events. */ + reset(): void; + /** + * Resolves when all currently in-flight plugin dispatches have settled. + * Useful for holding back navigation until analytics events have been sent. + * Note: events still in the queue (waiting for plugins to load) are not awaited. + */ + drain(): Promise; +}; + +export type ReactiveAnalyticsControls = AnalyticsControls & { + /** Number of events waiting in the queue (plugins not yet ready). */ + pendingCount: Accessor; +}; diff --git a/packages/analytics/stories/analytics.stories.tsx b/packages/analytics/stories/analytics.stories.tsx new file mode 100644 index 000000000..db69ae1cc --- /dev/null +++ b/packages/analytics/stories/analytics.stories.tsx @@ -0,0 +1,249 @@ +import { createSignal } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { AnalyticsProvider, createAnalytics, useAnalytics } from "@solid-primitives/analytics"; +import type { AnalyticsPlugin } from "@solid-primitives/analytics"; +import rawReadme from "../README.md?raw"; +import { + Button, + ButtonRow, + colors, + Container, + EventLog, + Section, + StatRow, +} from "../../../.storybook/ui/index.js"; + +// Strip YAML frontmatter so it doesn't appear as raw text in the Docs page. +const readme = rawReadme.replace(/^---[\s\S]*?---\n/, ""); + +const meta = preview.meta({ + title: "Utilities/Analytics", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { component: readme }, + }, + }, +}); + +export default meta; + +export const QueueAndReplayStory = meta.story({ + name: "Queue & replay on plugin ready", + parameters: { + docs: { + description: { + story: + "Events fired before all plugins have finished initializing accumulate in a bounded queue. Once every plugin's `loaded()` check passes, the queue drains in order and `pendingCount` resets to zero. Click the buttons immediately after mount — the plugin resolves after 1.5 s — to see events queue up and then replay automatically.", + }, + }, + }, + render: () => { + const [log, setLog] = createSignal<{ label: string; time: string }[]>([]); + const addLog = (label: string) => + setLog(prev => [{ label, time: new Date().toLocaleTimeString() }, ...prev].slice(0, 8)); + + let pluginLoaded = false; + const plugin: AnalyticsPlugin = { + name: "demo-logger", + initialize: () => + new Promise(resolve => + setTimeout(() => { + pluginLoaded = true; + resolve(); + }, 1500), + ), + loaded: () => pluginLoaded, + page: ({ payload }) => + addLog(`page "${payload.properties.title ?? payload.properties.path ?? "/"}"`), + track: ({ payload }) => addLog(`track "${payload.event}"`), + identify: ({ payload }) => addLog(`identify "${payload.userId}"`), + }; + + const analytics = createAnalytics([plugin]); + + return ( + + + + + + + + +

+ Plugin resolves after 1.5 s. Events queued before that replay in order — watch{" "} + pendingCount drop and the log populate. +

+ + ); + }, +}); + +export const BatchedDrainStory = meta.story({ + name: "Batched dispatch window", + parameters: { + docs: { + description: { + story: + "Setting `drainInterval` switches the engine to batch mode: events accumulate and flush on a timer rather than immediately. `drainSize` caps the number of events dispatched per cycle — the remainder stays queued for the next tick. Useful for reducing network calls when events fire at high frequency.", + }, + }, + }, + render: () => { + const [log, setLog] = createSignal<{ label: string; time: string }[]>([]); + const addLog = (label: string) => + setLog(prev => [{ label, time: new Date().toLocaleTimeString() }, ...prev].slice(0, 10)); + + const DRAIN_INTERVAL = 3000; + const DRAIN_SIZE = 3; + let batchN = 0; + + const plugin: AnalyticsPlugin = { + name: "batch-logger", + track: ({ payload }) => addLog(`[batch ${++batchN}] ${payload.event}`), + }; + + const analytics = createAnalytics([plugin], { + drainInterval: DRAIN_INTERVAL, + drainSize: DRAIN_SIZE, + }); + + return ( + + + + + + + + + +

+ Events flush up to {DRAIN_SIZE} at a time every {DRAIN_INTERVAL / 1000} s. The queue beyond + that carries forward to the next cycle. +

+
+ ); + }, +}); + +export const ContextSharingStory = meta.story({ + name: "Single provider, many consumers", + parameters: { + docs: { + description: { + story: + "`AnalyticsProvider` creates one shared `createAnalytics` instance and exposes it via context. Any descendant calls `useAnalytics()` to get the same controls — no prop-drilling needed. Both sections below fire to the same log through the shared instance.", + }, + }, + }, + render: () => { + const [log, setLog] = createSignal<{ label: string; time: string }[]>([]); + const addLog = (kind: string, detail: string) => + setLog(prev => + [{ label: `[${kind}] ${detail}`, time: new Date().toLocaleTimeString() }, ...prev].slice( + 0, + 10, + ), + ); + + const plugin: AnalyticsPlugin = { + name: "context-demo", + page: ({ payload }) => addLog("page", payload.properties.path ?? "/"), + track: ({ payload }) => addLog("track", payload.event), + identify: ({ payload }) => addLog("identify", payload.userId), + }; + + const HeroSection = () => { + const analytics = useAnalytics(); + return ( + + + + + ); + }; + + const CartSection = () => { + const analytics = useAnalytics(); + return ( + + + + + ); + }; + + return ( + + +
+ +
+
+ +
+ +
+
+ ); + }, +}); diff --git a/packages/analytics/test/index.test.ts b/packages/analytics/test/index.test.ts index 612a49856..8c804899f 100644 --- a/packages/analytics/test/index.test.ts +++ b/packages/analytics/test/index.test.ts @@ -1,18 +1,457 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; +import { createRoot, flush } from "solid-js"; +import { makeAnalytics, createAnalytics } from "../src/analytics.js"; +import { makeAnalyticsGuard, createAnalyticsGuard } from "../src/guard.js"; +import type { AnalyticsPlugin, PagePayload, TrackPayload, IdentifyPayload } from "../src/types.js"; +import type { BeforeLeaveEvent } from "../src/guard.js"; -import createAnalytics, { EventType } from "../src/index.js"; +function makeReadyPlugin(overrides: Partial = {}): AnalyticsPlugin { + return { name: "ready", ...overrides }; +} -describe("createPrimitiveTemplate", () => { - it("track function calls all handlers with the event", () => { - const called: any[][] = []; - const handlers = [...new Array(5)].map( - (_, i) => - (...args) => - (called[i] = args), +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +// ─── makeAnalytics ──────────────────────────────────────────────────────────── + +describe("makeAnalytics", () => { + it("dispatches page events immediately to a ready plugin", async () => { + const pageSpy = vi.fn(); + const [analytics, cleanup] = makeAnalytics([makeReadyPlugin({ page: ({ payload }) => pageSpy(payload) })]); + analytics.page({ title: "Home" }); + await vi.advanceTimersByTimeAsync(0); + expect(pageSpy).toHaveBeenCalledOnce(); + const payload = pageSpy.mock.calls[0]![0] as PagePayload; + expect(payload.type).toBe("page"); + expect(payload.properties.title).toBe("Home"); + expect(payload.meta.rid).toBeTruthy(); + cleanup(); + }); + + it("dispatches track events to a ready plugin", async () => { + const trackSpy = vi.fn(); + const [analytics, cleanup] = makeAnalytics([makeReadyPlugin({ track: ({ payload }) => trackSpy(payload) })]); + analytics.track("button_click", { name: "signup" }); + await vi.advanceTimersByTimeAsync(0); + expect(trackSpy).toHaveBeenCalledOnce(); + const payload = trackSpy.mock.calls[0]![0] as TrackPayload; + expect(payload.type).toBe("track"); + expect(payload.event).toBe("button_click"); + expect(payload.properties.name).toBe("signup"); + cleanup(); + }); + + it("dispatches identify events to a ready plugin", async () => { + const identifySpy = vi.fn(); + const [analytics, cleanup] = makeAnalytics([ + makeReadyPlugin({ identify: ({ payload }) => identifySpy(payload) }), + ]); + analytics.identify("user-1", { email: "alice@example.com" }); + await vi.advanceTimersByTimeAsync(0); + expect(identifySpy).toHaveBeenCalledOnce(); + const payload = identifySpy.mock.calls[0]![0] as IdentifyPayload; + expect(payload.type).toBe("identify"); + expect(payload.userId).toBe("user-1"); + expect(payload.traits.email).toBe("alice@example.com"); + cleanup(); + }); + + it("queues events while a plugin is initializing and flushes when ready", async () => { + let resolveInit!: () => void; + const initDone = new Promise(res => (resolveInit = res)); + const pageSpy = vi.fn(); + + const [analytics, cleanup] = makeAnalytics([ + { + name: "async-plugin", + initialize: () => initDone, + page: ({ payload }) => pageSpy(payload), + }, + ]); + + analytics.page({ title: "Queued" }); + await vi.advanceTimersByTimeAsync(0); // init not done yet + expect(pageSpy).not.toHaveBeenCalled(); + + resolveInit(); + await vi.advanceTimersByTimeAsync(20); // allow init + drain + expect(pageSpy).toHaveBeenCalledOnce(); + cleanup(); + }); + + it("dispatches to multiple plugins sequentially", async () => { + const order: string[] = []; + const pluginA: AnalyticsPlugin = { name: "a", track: async () => { order.push("a"); } }; + const pluginB: AnalyticsPlugin = { name: "b", track: async () => { order.push("b"); } }; + const [analytics, cleanup] = makeAnalytics([pluginA, pluginB]); + analytics.track("ev"); + await vi.advanceTimersByTimeAsync(10); + expect(order).toEqual(["a", "b"]); + cleanup(); + }); + + it("abort() stops subsequent plugins from receiving the event", async () => { + const spyA = vi.fn(); + const spyB = vi.fn(); + const pluginA: AnalyticsPlugin = { name: "a", track: ({ abort }) => { spyA(); abort(); } }; + const pluginB: AnalyticsPlugin = { name: "b", track: () => spyB() }; + const [analytics, cleanup] = makeAnalytics([pluginA, pluginB]); + analytics.track("ev"); + await vi.advanceTimersByTimeAsync(10); + expect(spyA).toHaveBeenCalledOnce(); + expect(spyB).not.toHaveBeenCalled(); + cleanup(); + }); + + it("swallows plugin errors and continues with the next plugin", async () => { + const spyB = vi.fn(); + const pluginA: AnalyticsPlugin = { + name: "broken", + track: () => { throw new Error("boom"); }, + }; + const pluginB: AnalyticsPlugin = { name: "ok", track: () => spyB() }; + const [analytics, cleanup] = makeAnalytics([pluginA, pluginB]); + analytics.track("ev"); + await vi.advanceTimersByTimeAsync(10); + expect(spyB).toHaveBeenCalledOnce(); + cleanup(); + }); + + it("dynamically registers a plugin via use()", async () => { + const spyA = vi.fn(); + const spyB = vi.fn(); + const pluginA: AnalyticsPlugin = { name: "a", track: () => spyA() }; + const pluginB: AnalyticsPlugin = { name: "b", track: () => spyB() }; + const [analytics, cleanup] = makeAnalytics([pluginA]); + analytics.use(pluginB); + analytics.track("ev"); + await vi.advanceTimersByTimeAsync(10); + expect(spyA).toHaveBeenCalledOnce(); + expect(spyB).toHaveBeenCalledOnce(); + cleanup(); + }); + + it("cleanup stops the poll timer and clears the queue", async () => { + let resolveInit!: () => void; + const initDone = new Promise(res => (resolveInit = res)); + const spy = vi.fn(); + const [analytics, cleanup] = makeAnalytics( + [{ name: "async", initialize: () => initDone, page: () => spy() }], + { retryInterval: 50 }, ); - const track = createAnalytics(handlers); - const eventData = {}; - track(EventType.Event, eventData); - expect(called).toEqual(new Array(5).fill([EventType.Event, eventData])); + analytics.page({ title: "Lost" }); + cleanup(); // discard before init resolves + resolveInit(); + await vi.advanceTimersByTimeAsync(100); + expect(spy).not.toHaveBeenCalled(); + }); + + it("uses loaded() to determine plugin readiness", async () => { + const spy = vi.fn(); + let isLoaded = false; + const plugin: AnalyticsPlugin = { + name: "deferred", + loaded: () => isLoaded, + page: () => spy(), + }; + const [analytics, cleanup] = makeAnalytics([plugin], { retryInterval: 20 }); + analytics.page({ title: "Deferred" }); + await vi.advanceTimersByTimeAsync(10); + expect(spy).not.toHaveBeenCalled(); + isLoaded = true; + await vi.advanceTimersByTimeAsync(50); // poll fires, drains queue + expect(spy).toHaveBeenCalledOnce(); + cleanup(); + }); + + it("each event gets a unique rid", async () => { + const rids: string[] = []; + const plugin: AnalyticsPlugin = { name: "p", track: ({ payload }) => rids.push(payload.meta.rid) }; + const [analytics, cleanup] = makeAnalytics([plugin]); + analytics.track("a"); + analytics.track("b"); + await vi.advanceTimersByTimeAsync(10); + expect(rids).toHaveLength(2); + expect(rids[0]).not.toBe(rids[1]); + cleanup(); + }); +}); + +// ─── createAnalytics ────────────────────────────────────────────────────────── + +describe("createAnalytics", () => { + let dispose: () => void; + + afterEach(() => { + dispose?.(); + }); + + it("pendingCount() starts at 0 with a sync plugin", () => { + createRoot(d => { + dispose = d; + const analytics = createAnalytics([makeReadyPlugin()]); + flush(); + expect(analytics.pendingCount()).toBe(0); + }); + }); + + it("pendingCount() reflects queued event count", async () => { + let resolveInit!: () => void; + const initDone = new Promise(res => (resolveInit = res)); + let analyticsRef!: ReturnType; + createRoot(d => { + dispose = d; + analyticsRef = createAnalytics([{ name: "async", initialize: () => initDone, page: () => {} }]); + analyticsRef.page({ title: "A" }); + analyticsRef.page({ title: "B" }); + flush(); + expect(analyticsRef.pendingCount()).toBe(2); + resolveInit(); + }); + await vi.advanceTimersByTimeAsync(20); + flush(); + expect(analyticsRef.pendingCount()).toBe(0); + }); + + it("reset() clears the queue and resets pendingCount", () => { + createRoot(d => { + dispose = d; + let resolveInit!: () => void; + const initDone = new Promise(res => (resolveInit = res)); + const analytics = createAnalytics([{ name: "async", initialize: () => initDone }]); + analytics.page(); + flush(); + expect(analytics.pendingCount()).toBe(1); + analytics.reset(); + flush(); + expect(analytics.pendingCount()).toBe(0); + resolveInit(); + }); + }); + + it("passes config to plugin handlers", async () => { + const spy = vi.fn(); + await new Promise(resolve => { + createRoot(d => { + dispose = d; + const plugin: AnalyticsPlugin = { + name: "cfg", + config: { apiKey: "abc" }, + track: ({ config }) => { spy(config); resolve(); }, + }; + createAnalytics([plugin]).track("ev"); + }); + }); + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "abc" })); + }); +}); + +// ─── drain ──────────────────────────────────────────────────────────────────── + +describe("drain()", () => { + it("resolves immediately when no dispatches are in flight", async () => { + const [analytics, cleanup] = makeAnalytics([makeReadyPlugin()]); + await expect(analytics.drain()).resolves.toBeUndefined(); + cleanup(); + }); + + it("resolves after in-flight async plugin calls complete", async () => { + let resolveTrack!: () => void; + const trackDone = new Promise(res => (resolveTrack = res)); + const order: string[] = []; + + const plugin: AnalyticsPlugin = { + name: "slow", + track: async () => { + await trackDone; + order.push("plugin"); + }, + }; + const [analytics, cleanup] = makeAnalytics([plugin]); + analytics.track("ev"); + const drained = analytics.drain().then(() => order.push("drained")); + await vi.advanceTimersByTimeAsync(5); + expect(order).toEqual([]); // neither done yet + resolveTrack(); + await drained; + expect(order).toEqual(["plugin", "drained"]); + cleanup(); + }); + + it("drain() on createAnalytics also works", async () => { + const spy = vi.fn(); + await new Promise(resolve => { + createRoot(async d => { + const analytics = createAnalytics([makeReadyPlugin({ track: async () => { spy(); } })]); + analytics.track("ev"); + await analytics.drain(); + expect(spy).toHaveBeenCalledOnce(); + d(); + resolve(); + }); + }); + }); +}); + +// ─── drainInterval / drainSize ──────────────────────────────────────────────── + +describe("drainInterval / drainSize", () => { + it("batches events and dispatches on the drain interval", async () => { + const spy = vi.fn(); + const plugin: AnalyticsPlugin = { name: "p", track: ({ payload }) => spy(payload.event) }; + const [analytics, cleanup] = makeAnalytics([plugin], { drainInterval: 30 }); + + analytics.track("a"); + analytics.track("b"); + analytics.track("c"); + + await vi.advanceTimersByTimeAsync(10); + expect(spy).not.toHaveBeenCalled(); // not dispatched yet + + await vi.advanceTimersByTimeAsync(30); // drain interval fires + expect(spy).toHaveBeenCalledTimes(3); + expect(spy.mock.calls.map(c => c[0])).toEqual(["a", "b", "c"]); + cleanup(); + }); + + it("drainSize limits events per cycle, leaving the rest for the next tick", async () => { + const spy = vi.fn(); + const plugin: AnalyticsPlugin = { name: "p", track: ({ payload }) => spy(payload.event) }; + const [analytics, cleanup] = makeAnalytics([plugin], { drainInterval: 30, drainSize: 2 }); + + analytics.track("a"); + analytics.track("b"); + analytics.track("c"); + + await vi.advanceTimersByTimeAsync(40); // first drain fires + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls.map(c => c[0])).toEqual(["a", "b"]); + + await vi.advanceTimersByTimeAsync(30); // second drain fires + expect(spy).toHaveBeenCalledTimes(3); + expect(spy.mock.calls[2]![0]).toBe("c"); + cleanup(); + }); + + it("pendingCount reflects queued events in batch mode", async () => { + const spy = vi.fn(); + let analyticsRef!: ReturnType; + let disposeRef!: () => void; + createRoot(d => { + disposeRef = d; + const plugin: AnalyticsPlugin = { name: "p", track: () => spy() }; + analyticsRef = createAnalytics([plugin], { drainInterval: 50 }); + analyticsRef.track("a"); + analyticsRef.track("b"); + flush(); + expect(analyticsRef.pendingCount()).toBe(2); + }); + await vi.advanceTimersByTimeAsync(60); + flush(); + expect(analyticsRef.pendingCount()).toBe(0); + expect(spy).toHaveBeenCalledTimes(2); + disposeRef(); + }); + + it("without drainInterval events dispatch immediately (default behavior unchanged)", async () => { + const spy = vi.fn(); + const plugin: AnalyticsPlugin = { name: "p", track: ({ payload }) => spy(payload.event) }; + const [analytics, cleanup] = makeAnalytics([plugin]); + analytics.track("immediate"); + await vi.advanceTimersByTimeAsync(5); + expect(spy).toHaveBeenCalledWith("immediate"); + cleanup(); + }); +}); + +// ─── makeAnalyticsGuard ─────────────────────────────────────────────────────── + +describe("makeAnalyticsGuard", () => { + it("onBeforeLeave prevents default and retries after drain", async () => { + let resolveTrack!: () => void; + const trackDone = new Promise(res => (resolveTrack = res)); + + const plugin: AnalyticsPlugin = { name: "p", track: () => trackDone }; + const [analytics, cleanupAnalytics] = makeAnalytics([plugin]); + analytics.track("ev"); // in-flight + + const { onBeforeLeave, cleanup } = makeAnalyticsGuard(analytics); + + const retried = vi.fn(); + const event: BeforeLeaveEvent = { + defaultPrevented: false, + preventDefault: vi.fn(), + retry: retried, + }; + + onBeforeLeave(event); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(retried).not.toHaveBeenCalled(); + + resolveTrack(); + await vi.advanceTimersByTimeAsync(10); + expect(retried).toHaveBeenCalledWith(true); + + cleanup(); + cleanupAnalytics(); + }); + + it("does not re-guard when already guarding", async () => { + let resolveTrack!: () => void; + const trackDone = new Promise(res => (resolveTrack = res)); + const plugin: AnalyticsPlugin = { name: "slow", track: () => trackDone }; + const [analytics, cleanupAnalytics] = makeAnalytics([plugin]); + analytics.track("ev"); // keep a dispatch in-flight so drain() doesn't resolve immediately + const { onBeforeLeave, cleanup } = makeAnalyticsGuard(analytics); + + const event: BeforeLeaveEvent = { + defaultPrevented: false, + preventDefault: vi.fn(), + retry: vi.fn(), + }; + + onBeforeLeave(event); // first call — sets guarding = true + onBeforeLeave(event); // second call while still guarding — should be ignored + expect(event.preventDefault).toHaveBeenCalledOnce(); + + resolveTrack(); + await vi.advanceTimersByTimeAsync(10); + cleanup(); + cleanupAnalytics(); + }); + + it("does nothing when event.defaultPrevented is true", () => { + const [analytics, cleanupAnalytics] = makeAnalytics([makeReadyPlugin()]); + const { onBeforeLeave, cleanup } = makeAnalyticsGuard(analytics); + + const event: BeforeLeaveEvent = { + defaultPrevented: true, + preventDefault: vi.fn(), + retry: vi.fn(), + }; + onBeforeLeave(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + + cleanup(); + cleanupAnalytics(); + }); +}); + +// ─── createAnalyticsGuard ───────────────────────────────────────────────────── + +describe("createAnalyticsGuard", () => { + let dispose: () => void; + afterEach(() => dispose?.()); + + it("auto-removes beforeunload listener on owner disposal", () => { + const removeSpy = vi.spyOn(window, "removeEventListener"); + createRoot(d => { + dispose = d; + const analytics = createAnalytics([makeReadyPlugin()]); + createAnalyticsGuard(analytics); + }); + dispose(); + expect(removeSpy).toHaveBeenCalledWith("beforeunload", expect.any(Function), undefined); + removeSpy.mockRestore(); }); }); diff --git a/packages/analytics/test/server.test.ts b/packages/analytics/test/server.test.ts new file mode 100644 index 000000000..05d5e32e4 --- /dev/null +++ b/packages/analytics/test/server.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi } from "vitest"; +import { createRoot, flush } from "solid-js"; +import { makeAnalytics, createAnalytics } from "../src/analytics.js"; +import type { AnalyticsPlugin, PagePayload, TrackPayload, IdentifyPayload } from "../src/types.js"; + +// These tests run with --mode ssr (isServer = true in @solidjs/web). +// Plugins that don't touch browser APIs work identically on client and server. + +function delay(ms = 0): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// ─── makeAnalytics (server) ─────────────────────────────────────────────────── + +describe("makeAnalytics (server)", () => { + it("dispatches page events to a synchronous plugin", async () => { + const spy = vi.fn(); + const plugin: AnalyticsPlugin = { name: "logger", page: ({ payload }) => spy(payload) }; + const [analytics, cleanup] = makeAnalytics([plugin]); + + analytics.page({ title: "SSR Home", path: "/home" }); + await analytics.drain(); + + expect(spy).toHaveBeenCalledOnce(); + const payload = spy.mock.calls[0]![0] as PagePayload; + expect(payload.type).toBe("page"); + expect(payload.properties.title).toBe("SSR Home"); + expect(payload.properties.path).toBe("/home"); + // Browser defaults (window.location etc.) are absent on the server + expect(payload.properties.url).toBeUndefined(); + expect(payload.meta.rid).toBeTruthy(); + cleanup(); + }); + + it("dispatches track events to a synchronous plugin", async () => { + const spy = vi.fn(); + const plugin: AnalyticsPlugin = { name: "logger", track: ({ payload }) => spy(payload) }; + const [analytics, cleanup] = makeAnalytics([plugin]); + + analytics.track("server_render", { route: "/home", user: "u-1" }); + await analytics.drain(); + + expect(spy).toHaveBeenCalledOnce(); + const payload = spy.mock.calls[0]![0] as TrackPayload; + expect(payload.type).toBe("track"); + expect(payload.event).toBe("server_render"); + expect(payload.properties.route).toBe("/home"); + cleanup(); + }); + + it("dispatches identify events to a synchronous plugin", async () => { + const spy = vi.fn(); + const plugin: AnalyticsPlugin = { name: "logger", identify: ({ payload }) => spy(payload) }; + const [analytics, cleanup] = makeAnalytics([plugin]); + + analytics.identify("u-42", { role: "admin", plan: "enterprise" }); + await analytics.drain(); + + expect(spy).toHaveBeenCalledOnce(); + const payload = spy.mock.calls[0]![0] as IdentifyPayload; + expect(payload.type).toBe("identify"); + expect(payload.userId).toBe("u-42"); + expect(payload.traits.role).toBe("admin"); + cleanup(); + }); + + it("queues events while a plugin initializes asynchronously", async () => { + let resolveInit!: () => void; + const initDone = new Promise(res => (resolveInit = res)); + const spy = vi.fn(); + const plugin: AnalyticsPlugin = { + name: "async-logger", + initialize: () => initDone, + track: ({ payload }) => spy(payload), + }; + const [analytics, cleanup] = makeAnalytics([plugin]); + + analytics.track("queued_on_server"); + await delay(); // init not yet resolved + expect(spy).not.toHaveBeenCalled(); + + resolveInit(); + await delay(20); + expect(spy).toHaveBeenCalledOnce(); + cleanup(); + }); + + it("dispatches to multiple server plugins sequentially", async () => { + const order: string[] = []; + const pluginA: AnalyticsPlugin = { name: "a", track: async () => { order.push("a"); } }; + const pluginB: AnalyticsPlugin = { name: "b", track: async () => { order.push("b"); } }; + const [analytics, cleanup] = makeAnalytics([pluginA, pluginB]); + + analytics.track("ev"); + await analytics.drain(); + + expect(order).toEqual(["a", "b"]); + cleanup(); + }); + + it("abort() works on the server", async () => { + const spyA = vi.fn(); + const spyB = vi.fn(); + const pluginA: AnalyticsPlugin = { name: "a", track: ({ abort }) => { spyA(); abort(); } }; + const pluginB: AnalyticsPlugin = { name: "b", track: () => spyB() }; + const [analytics, cleanup] = makeAnalytics([pluginA, pluginB]); + + analytics.track("ev"); + await analytics.drain(); + + expect(spyA).toHaveBeenCalledOnce(); + expect(spyB).not.toHaveBeenCalled(); + cleanup(); + }); + + it("drain() resolves after all server dispatches settle", async () => { + let resolveTrack!: () => void; + const trackDone = new Promise(res => (resolveTrack = res)); + const order: string[] = []; + const plugin: AnalyticsPlugin = { + name: "slow", + track: async () => { await trackDone; order.push("plugin"); }, + }; + const [analytics, cleanup] = makeAnalytics([plugin]); + + analytics.track("ev"); + const drained = analytics.drain().then(() => order.push("drained")); + + expect(order).toEqual([]); + resolveTrack(); + await drained; + + expect(order).toEqual(["plugin", "drained"]); + cleanup(); + }); +}); + +// ─── createAnalytics (server) ───────────────────────────────────────────────── + +describe("createAnalytics (server)", () => { + it("dispatches events inside a createRoot scope on the server", async () => { + const spy = vi.fn(); + const plugin: AnalyticsPlugin = { name: "logger", track: ({ payload }) => spy(payload) }; + + await new Promise(resolve => { + createRoot(async dispose => { + const analytics = createAnalytics([plugin]); + flush(); + expect(analytics.pendingCount()).toBe(0); + + analytics.track("ssr_event", { ctx: "server" }); + await analytics.drain(); + + expect(spy).toHaveBeenCalledOnce(); + const payload = spy.mock.calls[0]![0] as TrackPayload; + expect(payload.event).toBe("ssr_event"); + dispose(); + resolve(); + }); + }); + }); + + it("pendingCount() is 0 immediately for synchronous plugins on the server", () => { + createRoot(dispose => { + const plugin: AnalyticsPlugin = { name: "sync" }; + const analytics = createAnalytics([plugin]); + flush(); + expect(analytics.pendingCount()).toBe(0); + dispose(); + }); + }); + + it("pendingCount() tracks queued events on the server", () => + new Promise(resolve => { + createRoot(d => { + let resolveInit!: () => void; + const initDone = new Promise(res => (resolveInit = res)); + const plugin: AnalyticsPlugin = { + name: "async", + initialize: () => initDone, + track: () => {}, + }; + const analytics = createAnalytics([plugin]); + analytics.track("a"); + analytics.track("b"); + flush(); + expect(analytics.pendingCount()).toBe(2); + + resolveInit(); + delay(20).then(() => { + flush(); + expect(analytics.pendingCount()).toBe(0); + d(); + resolve(); + }); + }); + })); + + it("page() omits browser-only defaults on the server", async () => { + const spy = vi.fn(); + const plugin: AnalyticsPlugin = { name: "logger", page: ({ payload }) => spy(payload) }; + + await new Promise(resolve => { + createRoot(async dispose => { + const analytics = createAnalytics([plugin]); + analytics.page({ title: "SSR Page" }); + await analytics.drain(); + const payload = spy.mock.calls[0]![0] as PagePayload; + expect(payload.properties.title).toBe("SSR Page"); + expect(payload.properties.url).toBeUndefined(); + dispose(); + resolve(); + }); + }); + }); +}); diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json index 38c71ce71..e950c5c9d 100644 --- a/packages/analytics/tsconfig.json +++ b/packages/analytics/tsconfig.json @@ -5,7 +5,17 @@ "outDir": "dist", "rootDir": "src" }, - "references": [], + "references": [ + { + "path": "../event-listener" + }, + { + "path": "../queue" + }, + { + "path": "../utils" + } + ], "include": [ "src" ] diff --git a/packages/queue/src/task-queue.ts b/packages/queue/src/task-queue.ts index 8a2583075..2d188f956 100644 --- a/packages/queue/src/task-queue.ts +++ b/packages/queue/src/task-queue.ts @@ -94,7 +94,7 @@ export function createTaskQueue(): ReactiveTaskQueue { const entry = tasks.shift()!; setSize(tasks.length); try { - entry.resolve((await entry.fn()) as T); + entry.resolve(await entry.fn()); } catch (err) { entry.reject(err); } @@ -166,7 +166,7 @@ export function createConcurrentTaskQueue(concurrency: number): ReactiveConcu (async () => { try { - entry.resolve((await entry.fn()) as T); + entry.resolve(await entry.fn()); } catch (err) { entry.reject(err); } finally { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c13ddf0ca..1cc6e9f0b 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -102,6 +102,13 @@ export function reverseChain( export const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max); +/** Creates an ID generator with its own isolated counter and per-generator random segment. Each call to the returned function produces a `timestamp-sequence-random` string that is unique within the generator and with high probability unique across independent generators (e.g. separate processes or SSR requests). */ +export const createIdGenerator = (): (() => string) => { + let seq = 0; + const rand = Math.random().toString(36).slice(2, 8); + return () => `${Date.now().toString(36)}-${(++seq).toString(36)}-${rand}`; +}; + /** * Accesses the value of a MaybeAccessor * @example