diff --git a/docs/guides/consent-management.md b/docs/guides/consent-management.md index 30626a9..25d6ea9 100644 --- a/docs/guides/consent-management.md +++ b/docs/guides/consent-management.md @@ -143,7 +143,9 @@ import { ### 1. Explicit banner (GDPR-style) ```ts -const analytics = ({ consent: { initial: 'pending', requireExplicit: true } }); +const analytics = createAnalytics({ + consent: { initialStatus: 'pending', requireExplicit: true }, +}); if (getConsent()?.status === 'pending') showBanner(); @@ -154,8 +156,8 @@ reject.onclick = () => denyConsent(); ### 2. Implicit grant on first interaction (opt-out) ```ts -const analytics = ({ - consent: { initial: 'pending', requireExplicit: false }, +const analytics = createAnalytics({ + consent: { initialStatus: 'pending', requireExplicit: false }, }); // First analytics event after user interacts can auto-promote to 'granted'. // (Never auto-promotes if requireExplicit is true.) @@ -164,8 +166,8 @@ const analytics = ({ ### 3. Policy version bumps (force re-consent) ```ts -const analytics = ({ - consent: { policyVersion: '2024-10-01', initial: 'pending' }, +const analytics = createAnalytics({ + consent: { policyVersion: '2024-10-01', initialStatus: 'pending' }, }); // If stored policyVersion differs, consent resets to 'pending'. ``` @@ -238,15 +240,15 @@ If your CMP provides granular categories, gate calls accordingly and keep Trackk * **Unit/integration tests:** simulate each initial state and verify queue/flush/drop: - * `initial: 'pending'` → queue, then `grantConsent()` → flush - * `initial: 'denied'` with/without `allowEssentialOnDenied` → drop/allow essential + * `initialStatus: 'pending'` → queue, then `grantConsent()` → flush + * `initialStatus: 'denied'` with/without `allowEssentialOnDenied` → drop/allow essential * `policyVersion` bump → stored consent invalidated → back to `pending` * Use small `await Promise.resolve()` (or a short `setTimeout(0)`) to let async flushes complete. ## Best Practices -1. **Default to explicit consent** unless your legal basis differs (`requireExplicit: true`, `initial: 'pending'`). +1. **Default to explicit consent** unless your legal basis differs (`requireExplicit: true`, `initialStatus: 'pending'`). 2. **Version your policy** and rotate `policyVersion` when language meaningfully changes. 3. **Don’t rely on auto-promotion** if you need an auditable explicit signal. 4. **Load extras after grant** (pixels, replayers) via `onConsentChange`. diff --git a/docs/guides/custom-providers.md b/docs/guides/custom-providers.md index 921d2e5..d4d00b1 100644 --- a/docs/guides/custom-providers.md +++ b/docs/guides/custom-providers.md @@ -105,7 +105,7 @@ export interface MyProviderOptions { } ``` -These options will later be merged into the main `InitOptions` via `providerOptions` or a dedicated `myProvider` block (depending on how you design it). Keep them minimal and explicit. +These options will later be passed through the `provider` field in `AnalyticsOptions` when you configure your analytics instance. Keep them minimal and explicit. ## Step 2 – Implement the adapter @@ -251,19 +251,17 @@ Once the provider is wired into the registry and the type union: import { createAnalytics } from 'trackkit'; const analytics = createAnalytics({ - provider: 'myprovider', - site: '…', // if you need it - // or preferably a dedicated options block based on your types: - providerOptions: { - myprovider: { - apiKey: '…', - endpoint: 'https://api.example-analytics.com', - } - } + provider: { + name: 'myprovider', + site: '…', // if you need it + // Provider-specific options are passed through the provider object: + apiKey: '…', + endpoint: 'https://api.example-analytics.com', + }, }); ``` -Match this to however you’ve wired provider-specific options into your `InitOptions`; use the existing Umami / Plausible / GA4 implementations as authoritative examples. +Match this to however you've wired provider-specific options into your `AnalyticsOptions`; use the existing Umami / Plausible / GA4 implementations as authoritative examples. ## Diagnostics & Snapshots diff --git a/docs/guides/queue-management.md b/docs/guides/queue-management.md index 023450c..dd1f2f8 100644 --- a/docs/guides/queue-management.md +++ b/docs/guides/queue-management.md @@ -1,6 +1,6 @@ # Queue Management -Trackkit buffers events when it’s *not safe* to send yet, then replays them in order once conditions are met. +Trackkit buffers events when it's *not safe* to send yet, then replays them in order once conditions are met. > **Want to see queue management in action?** > @@ -138,24 +138,45 @@ const analytics = createAnalytics({ debug: true }); const diagnostics = analytics.getDiagnostics(); /* { - id: 'AF_xxx', - hasProvider: true, - providerReady: true, - queueState: { ... }, - facadeQueueSize: 0, - ssrQueueSize: 0, - totalQueueSize: 0, - initializing: false, - provider: 'umami', - consent: 'granted', - debug: true, - lastSentUrl: '/current', - lastPlannedUrl: '/current' + timestamp: 1709553600000, + instanceId: 'AF_xxx', + config: { + autoTrack: true, + debug: true, + queueSize: 50, + trackLocalhost: true, + // ... other resolved config flags + }, + consent: { + status: 'granted', + }, + dispatcher: { + transportMode: 'smart', + batching: { enabled: false, ... }, + resilience: { detectBlockers: false, fallbackStrategy: 'proxy', ... }, + connection: { monitor: false, offlineStorage: false, ... }, + }, + provider: { + key: 'umami', + state: 'ready', + events: 5, + history: [ ], + }, + queue: { + totalBuffered: 0, + ssrQueueBuffered: 0, + facadeQueueBuffered: 0, + capacity: 50, + }, + urls: { + lastPlanned: '/current', + lastSent: '/current', + }, } */ ``` -You can safely read `facadeQueueSize`, `ssrQueueSize`, and `totalQueueSize` to understand what’s currently buffered. +You can safely read `queue.facadeQueueBuffered`, `queue.ssrQueueBuffered`, and `queue.totalBuffered` to understand what's currently buffered. ## SSR Flow diff --git a/docs/guides/resilience-and-transports.md b/docs/guides/resilience-and-transports.md index 8c96338..52c4d32 100644 --- a/docs/guides/resilience-and-transports.md +++ b/docs/guides/resilience-and-transports.md @@ -54,7 +54,7 @@ Other HTTP statuses are treated as permanent failures and are **not** retried. ### Customising retries -You can override retry options in your config (see your `AnalyticsOptions`): +You can override retry options via `dispatcher.resilience.retry` in your `AnalyticsOptions`: ```ts const analytics = createAnalytics({ @@ -62,13 +62,17 @@ const analytics = createAnalytics({ name: 'umami', site: '…', }, - retry: { - maxAttempts: 5, - initialDelay: 500, - maxDelay: 60000, - multiplier: 2, - jitter: true, - retryableStatuses: [408, 429, 500, 502, 503, 504], + dispatcher: { + resilience: { + retry: { + maxAttempts: 5, + initialDelay: 500, + maxDelay: 60000, + multiplier: 2, + jitter: true, + retryableStatuses: [408, 429, 500, 502, 503, 504], + }, + }, }, }); ``` @@ -87,8 +91,9 @@ Resilience defaults (`RESILIENCE_DEFAULTS`) look like: ```ts export const RESILIENCE_DEFAULTS = { detectBlockers: false, - fallbackStrategy: 'smart' as const, // 'smart' | 'proxy' | 'beacon' | 'none' + fallbackStrategy: 'proxy' as const, // 'proxy' | 'beacon' proxy: undefined, + retry: { /* see RETRY_DEFAULTS above */ }, } as const; ``` @@ -112,28 +117,32 @@ Transport resolution (in resolve.ts) determines which low-level mechanism (`fetc ### Fallback Strategies -If a blocker is detected, Trackkit chooses a fallback based on `resilience.fallbackStrategy`: +If a blocker is detected, Trackkit chooses a fallback based on `dispatcher.resilience.fallbackStrategy`: -* `'smart'` (default): - * If `proxy.proxyUrl` is configured → uses `ProxiedTransport`. - * If no proxy is configured → uses `BeaconTransport` (often bypasses blockers). -* `'proxy'`: forces usage of `ProxiedTransport`. **Throws a configuration error** if `proxyUrl` is missing. Use this to ensure you don't accidentally send direct requests if proxying fails. +* `'proxy'` (default): forces usage of `ProxiedTransport`. **Throws a configuration error** if `proxyUrl` is missing. Use this to ensure you don't accidentally send direct requests if proxying fails. * `'beacon'`: forces usage of `BeaconTransport`. -* `'none'`: keeps using `FetchTransport`. The request will likely fail, but it adheres to strict compliance policies if you absolutely cannot use other methods. + +If you want no fallback at all, simply leave `detectBlockers: false` (the default). When blocker detection is disabled, Trackkit always uses the base transport (`FetchTransport`) regardless of `fallbackStrategy`. + +> **Note:** The default `transportMode` is `'smart'`, which handles the overall transport selection logic. `fallbackStrategy` only applies when `detectBlockers: true` *and* a blocker is detected. ### Configuring `resilience` +Resilience options live under `dispatcher.resilience` in your `AnalyticsOptions`: + ```ts const analytics = createAnalytics({ provider: { name: 'plausible', site: 'yourdomain.com' }, - resilience: { - detectBlockers: true, - fallbackStrategy: 'smart', - proxy: { - proxyUrl: '/api/trackkit-proxy', - token: process.env.TRACKKIT_PROXY_TOKEN, - headers: { - 'X-Trackkit-Source': 'web', + dispatcher: { + resilience: { + detectBlockers: true, + fallbackStrategy: 'proxy', // 'proxy' | 'beacon' + proxy: { + proxyUrl: '/api/trackkit-proxy', + token: process.env.TRACKKIT_PROXY_TOKEN, + headers: { + 'X-Trackkit-Source': 'web', + }, }, }, }, @@ -162,58 +171,56 @@ Benefits: ## Example strategies +All `resilience` options live under `dispatcher.resilience`: + **Baseline / early stage:** ```ts -resilience: { - detectBlockers: false, // off +dispatcher: { + resilience: { + detectBlockers: false, // off (default) + }, } ``` **Blocker-aware, no proxy:** ```ts -resilience: { - detectBlockers: true, - fallbackStrategy: 'beacon', -} -``` - -**Production with proxy (Safety First):** - -```ts -resilience: { - detectBlockers: true, - fallbackStrategy: 'proxy', // Throws if proxy config missing - proxy: { - proxyUrl: '/api/trackkit', - token: '…', +dispatcher: { + resilience: { + detectBlockers: true, + fallbackStrategy: 'beacon', }, } ``` -**Production with proxy (Auto-fallback):** +**Production with proxy:** ```ts -// Uses proxy if available, but allows falling back to beacon if proxy config is somehow invalid/missing at runtime -resilience: { - detectBlockers: true, - fallbackStrategy: 'smart', - proxy: { ... } +dispatcher: { + resilience: { + detectBlockers: true, + fallbackStrategy: 'proxy', // Throws if proxy config missing + proxy: { + proxyUrl: '/api/trackkit', + token: '…', + }, + }, } ``` -**Explicitly no fallback:** +**No fallback (default transport only):** ```ts -resilience: { - detectBlockers: true, - fallbackStrategy: 'none', +// Simply leave detectBlockers: false (the default). +// Trackkit always uses FetchTransport; no blocker detection runs. +dispatcher: { + resilience: { + detectBlockers: false, + }, } ``` -(events simply fail when blocked – rarely desirable, but available). - ## Interaction with queues and consent Resilience concerns **how** events are sent, not whether they *should* be sent. diff --git a/docs/overview/architecture.md b/docs/overview/architecture.md index 645e30b..349e749 100644 --- a/docs/overview/architecture.md +++ b/docs/overview/architecture.md @@ -59,11 +59,11 @@ The facade is the only layer that users call. It owns: --- -### Configuration (`src/config/schema.ts`, `src/util/env.ts`) +### Configuration (`src/facade/config.ts`, `src/util/env.ts`) Configuration is schema-driven: -* `config/schema.ts` defines the shape of `InitOptions` and how defaults are applied. +* `facade/config.ts` defines how `AnalyticsOptions` are merged and how defaults are applied. * `util/env.ts` reads build-time env (`TRACKKIT_*`, `VITE_TRACKKIT_*`, etc.) and runtime overrides (`window.__TRACKKIT_ENV__`, meta tags). The merge order (simplified): diff --git a/docs/overview/quickstart.md b/docs/overview/quickstart.md index de0c479..36e1c5a 100644 --- a/docs/overview/quickstart.md +++ b/docs/overview/quickstart.md @@ -72,6 +72,35 @@ analytics.track('signup_submitted', { }); ``` +### Typed events (optional) + +For compile-time checking of event names and properties, define an event map +and pass it as a type parameter: + +```ts +// analytics.ts +import { createAnalytics } from 'trackkit'; + +type MyEvents = { + signup_submitted: { plan: 'free' | 'pro'; source: string }; + purchase_completed: { amount: number; currency: string }; +}; + +export const analytics = createAnalytics({ + provider: { name: 'umami', site: '...' }, +}); + +analytics.track('signup_submitted', { plan: 'pro', source: 'hero' }); // ✅ +analytics.track('signup_submitted', { plan: 'gold' }); // ❌ type error +analytics.track('unknown_event'); // ❌ not in MyEvents +``` + +When no type parameter is supplied, `track()` accepts any string name and any +props — identical to untyped usage. + +> The singleton API does not support typed events. Use `createAnalytics()` +> for type-checked tracking. + ### Identify (where supported) ```ts @@ -204,3 +233,4 @@ const analytics = createAnalytics({ - [Plausible](/providers/plausible) - [Google Analytics 4](/providers/ga4) - See full SSR semantics: [SSR Guide](/guides/ssr) +- Typed events API details: [API Reference](/reference/api) diff --git a/docs/providers/ga4.md b/docs/providers/ga4.md index 6ab425e..572c937 100644 --- a/docs/providers/ga4.md +++ b/docs/providers/ga4.md @@ -46,9 +46,6 @@ const analytics = createAnalytics({ /* Auth (Optional but recommended for reliability) */ apiSecret: 'YOUR_API_SECRET', // Generated in GA4 Admin > Data Streams > API Secrets - - /* Metadata */ - defaultProps: { appVersion: '2.3.1' }, // merged into GA4 params }, /* Features */ @@ -182,7 +179,7 @@ Enable `debug: true` to see: ## Best Practices 1. Ensure a compliant consent flow. -2. Use `defaultProps` for application metadata. +2. Use custom event props for application metadata (e.g. `track('event', { appVersion: '2.3.1' })`). 3. Keep GA4 event names aligned with recommended GA4 semantics. 4. Consider using a proxy if you want full first-party deployment. diff --git a/docs/providers/umami.md b/docs/providers/umami.md index f176721..d45f6ad 100644 --- a/docs/providers/umami.md +++ b/docs/providers/umami.md @@ -44,9 +44,6 @@ const analytics = createAnalytics({ // Endpoint host: 'https://analytics.example.com', // Required for self-hosted instances - - // Metadata - defaultProps: { appVersion: '2.3.1' }, // Merged into every event }, // Features diff --git a/docs/reference/api.md b/docs/reference/api.md index bbb020f..f1b6f6a 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -55,7 +55,7 @@ import type { ### Initialization -#### `createAnalytics(opts?: AnalyticsOptions): AnalyticsInstance` +#### `createAnalytics(opts?: AnalyticsOptions): AnalyticsInstance` Factory API. Creates a **new analytics instance**. @@ -82,7 +82,7 @@ analytics.track('signup_completed', { plan: 'pro' }); * `AnalyticsOptions` configures provider, queue, consent, resilience, etc. See the Configuration guide for full fields. -#### `init(opts: InitOptions): void` +#### `init(opts?: AnalyticsOptions): AnalyticsFacade` Singleton API. Initialises the **global** analytics facade. @@ -122,7 +122,7 @@ Record a custom event. track('signup_completed', { plan: 'pro', source: 'landing' }); ``` -When using typed events via `createAnalytics`, `eventName` and `props` are type-checked. +When using typed events via `createAnalytics`, `eventName` and `props` are type-checked. Props are **required** when the event map declares required fields, and optional when all fields are optional (or when no event map is provided). #### `pageview(url?: string): void` @@ -233,9 +233,9 @@ Returns the underlying facade instance used by the singleton. This is primarily for introspection and advanced integrations. The type is intentionally loose; prefer `getDiagnostics()` for read-only state. -#### `flushIfReady(): Promise` +#### `flushIfReady(): Promise` -If the provider is initialised, flush any queued events immediately. +If the provider is initialised, flush any queued events immediately. Returns the number of events flushed. No-op if not ready. @@ -289,12 +289,12 @@ String union of supported provider keys, e.g.: #### `EventMap` / `AnyEventMap` -See Typed Events above. +* `EventMap`: `Record>` — a mapping from event names to their expected property shapes. Define your own event map to get compile-time checking of `track()` calls via `createAnalytics()`. +* `AnyEventMap`: convenience alias for the fully-open default map. When no type parameter is supplied, `AnyEventMap` is used and all event names and props are accepted. -* `EventMap`: `Record>` -* `AnyEventMap`: alias used as the default map. +> **Note:** The singleton API (`init` / `track` / `pageview`) does not support typed events. Use the factory API (`createAnalytics()`) for compile-time event checking. -#### `AnalyticsInstance` +#### `AnalyticsInstance` The shape of an analytics instance returned by `createAnalytics()`. Primarily used by TypeScript consumers for typed `track()`. @@ -349,7 +349,7 @@ export async function getServerSideProps(ctx) { } ``` -#### `pageview(path?: string | { path?: string; title?: string; referrer?: string }): void` +#### `pageview(url?: string): void` Record a SSR pageview event. @@ -362,7 +362,7 @@ export async function getServerSideProps(ctx) { } ``` -#### `identify(userId: string, traits?: Record): void` +#### `identify(userId: string | null): void` Record a SSR identify event, if you wish to associate the pageview with a user ID at render time. @@ -401,11 +401,11 @@ Return the number of events currently buffered in the SSR queue. Used by diagnostics and advanced monitoring. -#### `enqueueSSREvent(event: QueuedEvent): void` +#### `enqueueSSREvent(type: EventType, args: unknown[], category: ConsentCategory, pageContext?: PageContext): void` -Low-level API: push a raw queued event into the SSR queue. +Low-level API: push a raw event into the SSR queue. -Most users should prefer the higher-level `track`, `pageview`, and `identify` SSR helpers. +Most users should prefer the higher-level `ssrTrack`, `ssrPageview`, and `ssrIdentify` helpers. `enqueueSSREvent` exists for custom integrations, migrations, or when bridging from a legacy analytics system. @@ -413,7 +413,7 @@ Most users should prefer the higher-level `track`, `pageview`, and `identify` SS Although not a separate module, it’s worth highlighting how errors are surfaced. -### `onError` option (InitOptions) +### `onError` option (AnalyticsOptions) You can provide a global error handler: diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index bb3c923..196a1ac 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -151,17 +151,17 @@ Both APIs flow through the same normalisation / validation path. Some options are **only** expected to be set programmatically (not via env): -* `retry` -* `resilience` -* `connection` -* `performance` +* `dispatcher.resilience.retry` +* `dispatcher.resilience` (adblocker detection, fallback strategy, proxy) +* `dispatcher.connection` +* `dispatcher.performance` * detailed `consent` configuration -These shapes are defined in TypeScript; use your editor’s IntelliSense (or the source files) as the canonical reference. +The dispatcher-related options above live under the `dispatcher` key in `AnalyticsOptions`, while detailed `consent` configuration is defined at the top level of `AnalyticsOptions`. The shapes are defined in TypeScript; use your editor's IntelliSense (or the source files) as the canonical reference. ### Retry -Controls backoff and retry for network dispatch. +Controls backoff and retry for network dispatch. Retry options are nested under `dispatcher.resilience.retry`. Defaults (from `RETRY_DEFAULTS`): @@ -181,23 +181,30 @@ Override with: ```ts const analytics = createAnalytics({ // ... - retry: { - maxAttempts: 5, - initialDelay: 500, - maxDelay: 60000, + dispatcher: { + resilience: { + retry: { + maxAttempts: 5, + initialDelay: 500, + maxDelay: 60000, + }, + }, }, }); ``` ### Resilience & transports -Controls adblocker detection and transport fallback. Defaults (from `RESILIENCE_DEFAULTS`): +Controls adblocker detection and transport fallback. These options live under `dispatcher.resilience`. + +Defaults (from `RESILIENCE_DEFAULTS`): ```ts resilience: { detectBlockers: false, - fallbackStrategy: 'proxy', // 'proxy' | 'beacon' | 'none' + fallbackStrategy: 'proxy', // 'proxy' | 'beacon' proxy: undefined, + retry: { /* see above */ }, } ``` @@ -206,13 +213,15 @@ Example with a first-party proxy: ```ts const analytics = createAnalytics({ // ... - resilience: { - detectBlockers: true, - fallbackStrategy: 'proxy', - proxy: { - proxyUrl: '/api/trackkit-proxy', - token: process.env.TRACKKIT_PROXY_TOKEN, - headers: { 'X-Trackkit-Source': 'web' }, + dispatcher: { + resilience: { + detectBlockers: true, + fallbackStrategy: 'proxy', + proxy: { + proxyUrl: '/api/trackkit-proxy', + token: process.env.TRACKKIT_PROXY_TOKEN, + headers: { 'X-Trackkit-Source': 'web' }, + }, }, }, }); @@ -222,7 +231,7 @@ See `docs/guides/resilience-and-transports.md` for the full story. ### Connection & offline -Controls how Trackkit interprets connection health and, if you wire it, how you use offline storage. +Controls how Trackkit interprets connection health and, if you wire it, how you use offline storage. These options live under `dispatcher.connection`. Defaults (from `CONNECTION_DEFAULTS`): @@ -241,11 +250,13 @@ Example: ```ts const analytics = createAnalytics({ // ... - connection: { - monitor: true, - offlineStorage: true, - syncInterval: 15000, - slowThreshold: 5000, + dispatcher: { + connection: { + monitor: true, + offlineStorage: true, + syncInterval: 15000, + slowThreshold: 5000, + }, }, }); ``` @@ -254,7 +265,7 @@ The actual connection state / offline behaviour is driven by `ConnectionMonitor` ### Performance tracking -Controls the lightweight `PerformanceTracker`. +Controls the lightweight `PerformanceTracker`. These options live under `dispatcher.performance`. Defaults (from `PERFORMANCE_DEFAULTS`): @@ -270,8 +281,10 @@ Example: ```ts const analytics = createAnalytics({ // ... - performance: { - enabled: true, + dispatcher: { + performance: { + enabled: true, + }, }, }); ``` diff --git a/packages/trackkit/README.md b/packages/trackkit/README.md index 8872961..1564638 100644 --- a/packages/trackkit/README.md +++ b/packages/trackkit/README.md @@ -50,19 +50,31 @@ For full documentation, see the **Quickstart**, detailed guides, API reference, ## TypeScript niceties -Optionally type your events: +Define an event map and pass it as a type parameter to `createAnalytics` for +compile-time checking of `track()` calls: ```ts -type Events = { +import { createAnalytics } from 'trackkit'; + +type MyEvents = { signup_submitted: { plan: 'free' | 'pro' }; - purchase_completed: { amount: number; currency: 'USD'|'EUR' }; + purchase_completed: { amount: number; currency: string }; }; -// If your project exposes a TypedAnalytics<> helper, you can cast. -// Otherwise, just rely on your own wrappers/types around `track`. +const analytics = createAnalytics({ + provider: { name: 'umami', site: '...' }, +}); + +analytics.track('signup_submitted', { plan: 'pro' }); // ✅ compiles +analytics.track('signup_submitted', { plan: 'gold' }); // ❌ type error +analytics.track('signup_submited'); // ❌ typo caught ``` -(Trackkit’s core API is fully typed; strict event typing can be layered via your app types or helper wrappers.) +When no type parameter is supplied, behaviour is unchanged — any event name +and any props are accepted. + +> **Note:** The singleton API (`init` / `track`) does not support typed events. +> Use the factory API (`createAnalytics()`) for compile-time event checking. ## SSR diff --git a/packages/trackkit/package.json b/packages/trackkit/package.json index 4ae4baf..0416197 100644 --- a/packages/trackkit/package.json +++ b/packages/trackkit/package.json @@ -63,6 +63,7 @@ "lint": "eslint src test --fix", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:typecheck": "vitest --typecheck --run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", diff --git a/packages/trackkit/src/facade/index.ts b/packages/trackkit/src/facade/index.ts index 5cd7da7..3e6ee80 100644 --- a/packages/trackkit/src/facade/index.ts +++ b/packages/trackkit/src/facade/index.ts @@ -1,4 +1,4 @@ -import type { EventType, AnalyticsOptions, Props, ResolvedFacadeOptions, ResolvedProviderOptions } from '../types'; +import type { EventType, AnalyticsOptions, Props, ResolvedFacadeOptions, ResolvedProviderOptions, EventMap, AnyEventMap } from '../types'; import { resolveConfig } from './config'; import { ConsentManager } from '../consent/ConsentManager'; import type { ConsentCategory, ConsentStatus, ConsentStoredState } from '../consent/types'; @@ -42,7 +42,7 @@ function returnIfInitialized(obj: T | null, name?: string): T { return obj; } -export class AnalyticsFacade { +export class AnalyticsFacade { readonly id = `AF_${getId()}`; private config: { @@ -211,8 +211,13 @@ export class AnalyticsFacade { // === Public API (compat with README & legacy singleton) === - track(name: string, props?: Props, category = DEFAULT_CATEGORY) { - this.execute({ type: 'track', args: [name, props], category }); + track( + ...args: {} extends E[K] + ? [name: K, props?: E[K], category?: ConsentCategory] + : [name: K, props: E[K], category?: ConsentCategory] + ): void { + const [name, props, category = DEFAULT_CATEGORY] = args; + this.execute({ type: 'track', args: [name, props as Props], category }); } pageview(url?: string) { diff --git a/packages/trackkit/src/factory.ts b/packages/trackkit/src/factory.ts index 8094801..f998a9b 100644 --- a/packages/trackkit/src/factory.ts +++ b/packages/trackkit/src/factory.ts @@ -1,8 +1,10 @@ import { AnalyticsFacade } from './facade/index'; -import type { AnalyticsOptions } from './types'; +import type { AnalyticsOptions, EventMap, AnyEventMap } from './types'; -export function createAnalytics(opts?: AnalyticsOptions) { - const a = new AnalyticsFacade(); +export function createAnalytics( + opts?: AnalyticsOptions, +): AnalyticsFacade { + const a = new AnalyticsFacade(); if (opts) a.init(opts); return a; } diff --git a/packages/trackkit/src/types.ts b/packages/trackkit/src/types.ts index 9220f4a..11baf47 100644 --- a/packages/trackkit/src/types.ts +++ b/packages/trackkit/src/types.ts @@ -45,6 +45,36 @@ export type AnalyticsMode = 'singleton' | 'factory'; */ export type Props = Record; +/** + * A mapping from event names to their expected property shapes. + * + * Define your own event map to get compile-time checking of `track()` calls: + * + * ```ts + * type MyEvents = { + * signup_completed: { plan: 'free' | 'pro' }; + * purchase: { amount: number; currency: string }; + * }; + * + * const analytics = createAnalytics({ ... }); + * analytics.track('signup_completed', { plan: 'pro' }); // ✅ compiles + * analytics.track('signup_completed', { plan: 'gold' }); // ❌ type error + * ``` + * + * When no event map is supplied (the default), any string name and any + * props are accepted — identical to the pre-typed-events behaviour. + */ +export type EventMap = Record>; + +/** + * Convenience alias for the fully-open default event map. + * + * Equivalent to `Record>`. + * Used as the default type parameter so that unparameterised usage + * remains fully open. + */ +export type AnyEventMap = EventMap; + /** * Supported high-level analytics operations. * @@ -449,20 +479,53 @@ export interface ResolvedAnalyticsOptions { * Public analytics instance exposed by Trackkit. * * This is what callers interact with when using the facade API. + * + * The generic parameter `E` allows compile-time checking of `track()` calls + * against a user-defined event map. When omitted, all event names and props + * are accepted (fully open, same as pre-typed-events behaviour). + * + * @example + * ```ts + * type MyEvents = { + * signup: { plan: 'free' | 'pro' }; + * purchase: { amount: number; currency: string }; + * }; + * + * // Typed — track() is checked against MyEvents + * const analytics: AnalyticsInstance = createAnalytics({ ... }); + * analytics.track('signup', { plan: 'pro' }); // ✅ + * analytics.track('typo'); // ❌ type error + * + * // Untyped — any event name and any props accepted + * const open: AnalyticsInstance = createAnalytics({ ... }); + * open.track('anything', { any: 'props' }); // ✅ + * ``` + * + * @typeParam E - Event map type. Defaults to {@link AnyEventMap} (fully open). */ -export interface AnalyticsInstance { +export interface AnalyticsInstance { /** Name of the active provider (e.g. `'ga4'`, `'plausible'`). */ name: string; /** * Track a custom event. * - * @param name - Event name (e.g. `"button_click"`). - * @param props - Optional event properties (JSON-serialisable). + * When `E` is a concrete event map, both `name` and `props` are + * type-checked against it. Props are **required** when the event map + * declares required fields, and optional when all fields are optional + * (or when using the default {@link AnyEventMap}). + * + * @typeParam K - Inferred from the event name literal. + * @param name - Event name (must be a key of `E`). + * @param props - Event properties (must match `E[K]`). * @param category - Optional consent category; defaults to `'analytics'` * in most setups. See {@link ConsentCategory}. */ - track(name: string, props?: Props, category?: ConsentCategory): void; + track( + ...args: {} extends E[K] + ? [name: K, props?: E[K], category?: ConsentCategory] + : [name: K, props: E[K], category?: ConsentCategory] + ): void; /** * Track a page view using the current page context. diff --git a/packages/trackkit/test/unit/facade/typed-events.test.ts b/packages/trackkit/test/unit/facade/typed-events.test.ts new file mode 100644 index 0000000..28a9388 --- /dev/null +++ b/packages/trackkit/test/unit/facade/typed-events.test.ts @@ -0,0 +1,105 @@ +/** + * Runtime behaviour tests for typed events. + * + * Verifies that the generic type parameter does not affect runtime + * behaviour — events still flow through to the provider correctly + * after generic erasure at the execute() boundary. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createAnalytics } from '../../../src/factory'; +import { createStatefulMock } from '../../helpers/providers'; +import { resetTests } from '../../helpers/core'; + +type TestEvents = { + signup: { plan: 'free' | 'pro' }; + purchase: { amount: number; currency: string }; +}; + +function resetEnv() { + history.replaceState(null, '', '/'); + try { localStorage.removeItem('__trackkit_consent__'); } catch {} + try { Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true }); } catch { (globalThis as any).doNotTrack = '0'; } +} + +describe('Typed events — runtime behaviour', () => { + beforeEach(() => { + resetTests(); + resetEnv(); + }); + + afterEach(() => { + resetTests(); + }); + + it('typed track() calls flow through to the provider with correct arguments', async () => { + const analytics = createAnalytics(); + const { stateful, provider } = await createStatefulMock(); + + analytics.setProvider(stateful); + analytics.init({ + autoTrack: false, + trackLocalhost: true, + domains: ['localhost'], + consent: { disablePersistence: true, initialStatus: 'granted' }, + }); + + await analytics.waitForReady(); + + analytics.track('signup', { plan: 'pro' }); + analytics.track('purchase', { amount: 99, currency: 'USD' }); + + expect(provider.diagnostics.eventCalls).toHaveLength(2); + expect(provider.diagnostics.eventCalls[0]!.name).toBe('signup'); + expect(provider.diagnostics.eventCalls[0]!.props).toEqual({ plan: 'pro' }); + expect(provider.diagnostics.eventCalls[1]!.name).toBe('purchase'); + expect(provider.diagnostics.eventCalls[1]!.props).toEqual({ amount: 99, currency: 'USD' }); + + analytics.destroy(); + }); + + it('typed instance still handles pageview and identify normally', async () => { + const analytics = createAnalytics(); + const { stateful, provider } = await createStatefulMock(); + + analytics.setProvider(stateful); + analytics.init({ + autoTrack: false, + trackLocalhost: true, + domains: ['localhost'], + consent: { disablePersistence: true, initialStatus: 'granted' }, + }); + + await analytics.waitForReady(); + + analytics.pageview(); + analytics.identify('user-123'); + + expect(provider.diagnostics.pageviewCalls).toHaveLength(1); + expect(provider.diagnostics.identifyCalls).toEqual(['user-123']); + + analytics.destroy(); + }); + + it('untyped createAnalytics still works at runtime', async () => { + const analytics = createAnalytics(); + const { stateful, provider } = await createStatefulMock(); + + analytics.setProvider(stateful); + analytics.init({ + autoTrack: false, + trackLocalhost: true, + domains: ['localhost'], + consent: { disablePersistence: true, initialStatus: 'granted' }, + }); + + await analytics.waitForReady(); + + analytics.track('any_event', { anything: true }); + + expect(provider.diagnostics.eventCalls).toHaveLength(1); + expect(provider.diagnostics.eventCalls[0]!.name).toBe('any_event'); + expect(provider.diagnostics.eventCalls[0]!.props).toEqual({ anything: true }); + + analytics.destroy(); + }); +}); diff --git a/packages/trackkit/test/unit/types/typed-events.test-d.ts b/packages/trackkit/test/unit/types/typed-events.test-d.ts new file mode 100644 index 0000000..94b7293 --- /dev/null +++ b/packages/trackkit/test/unit/types/typed-events.test-d.ts @@ -0,0 +1,118 @@ +/** + * Compile-time type tests for the typed events feature. + * + * These tests use vitest's `expectTypeOf` / `assertType` to verify that + * the generic parameter on `createAnalytics()` correctly constrains + * `track()` calls at the type level. + * + * Run with: `pnpm vitest typecheck` + */ +import { describe, it, expectTypeOf, assertType } from 'vitest'; +import { createAnalytics } from '../../../src/factory'; +import { track as singletonTrack } from '../../../src/facade/singleton'; +import type { AnalyticsInstance, EventMap, AnyEventMap } from '../../../src/types'; +import type { AnalyticsFacade } from '../../../src/facade/index'; + +// -- Test event maps --- + +type MyEvents = { + signup_completed: { plan: 'free' | 'pro' }; + purchase: { amount: number; currency: string }; + page_scrolled: { depth: number }; +}; + +type AllOptionalEvents = { + button_click: { label?: string; variant?: string }; +}; + +type NarrowEvents = { + only_event: { value: number }; +}; + +// -- Tests --- + +describe('Typed events — compile-time checks', () => { + it('unparameterised createAnalytics accepts any string name and any props', () => { + const analytics = createAnalytics(); + assertType(analytics.track('anything', { any: 'props' })); + assertType(analytics.track('something_else')); + assertType(analytics.track('event', { nested: { deep: true } })); + }); + + it('typed createAnalytics accepts known event names with correct props', () => { + const analytics = createAnalytics(); + assertType(analytics.track('signup_completed', { plan: 'pro' })); + assertType(analytics.track('purchase', { amount: 99, currency: 'USD' })); + assertType(analytics.track('page_scrolled', { depth: 42 })); + }); + + it('typed createAnalytics rejects wrong props', () => { + const analytics = createAnalytics(); + + // @ts-expect-error — 'gold' is not in 'free' | 'pro' + analytics.track('signup_completed', { plan: 'gold' }); + + // @ts-expect-error — 'amount' should be number, not string + analytics.track('purchase', { amount: 'a lot', currency: 'USD' }); + }); + + it('typed createAnalytics rejects unknown event names', () => { + const analytics = createAnalytics(); + + // @ts-expect-error — 'unknown_event' is not in MyEvents + analytics.track('unknown_event'); + + // @ts-expect-error — typo: 'signup_complted' is not in MyEvents + analytics.track('signup_complted'); + }); + + it('typed track allows omitting props when all fields are optional', () => { + const analytics = createAnalytics(); + // No props — should compile because both fields are optional + assertType(analytics.track('button_click')); + assertType(analytics.track('button_click', { label: 'ok' })); + assertType(analytics.track('button_click', {})); + }); + + it('typed track requires props when event has required fields', () => { + const analytics = createAnalytics(); + + // @ts-expect-error — props are required for 'purchase' (has required fields) + analytics.track('purchase'); + + // @ts-expect-error — props are required for 'page_scrolled' (has required fields) + analytics.track('page_scrolled'); + }); + + it('singleton track accepts any string and any props (untyped)', () => { + assertType(singletonTrack('anything', { foo: 'bar' })); + assertType(singletonTrack('something')); + assertType(singletonTrack('event', { a: 1, b: 'two' })); + }); + + it('AnalyticsFacade.track matches AnalyticsInstance.track', () => { + // The facade's track method should have the same type as the interface's + type FacadeTrack = AnalyticsFacade['track']; + type InstanceTrack = AnalyticsInstance['track']; + expectTypeOf().toEqualTypeOf(); + }); + + it('AnalyticsFacade.track differs from AnalyticsInstance.track', () => { + type FacadeTrack = AnalyticsFacade['track']; + type NarrowTrack = AnalyticsInstance['track']; + expectTypeOf().not.toEqualTypeOf(); + }); + + it('default type parameter is AnyEventMap', () => { + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); + }); + + it('EventMap is Record>', () => { + expectTypeOf().toEqualTypeOf>>(); + }); + + it('AnyEventMap equals EventMap', () => { + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/packages/trackkit/vitest.config.ts b/packages/trackkit/vitest.config.ts index 8d6dff8..fcbb872 100644 --- a/packages/trackkit/vitest.config.ts +++ b/packages/trackkit/vitest.config.ts @@ -19,6 +19,10 @@ export default defineConfig({ ...configDefaults.exclude, '**/e2e/**', ], + typecheck: { + enabled: true, + include: ['test/**/*.test-d.ts'], + }, coverage: { provider: 'v8', reportsDirectory: './coverage',