diff --git a/.changeset/standard-schema-events.md b/.changeset/standard-schema-events.md new file mode 100644 index 0000000..6dba10c --- /dev/null +++ b/.changeset/standard-schema-events.md @@ -0,0 +1,5 @@ +--- +"trakoo": major +--- + +Replace type-asserted event collections with runtime `defineEvents()` registries based on Standard Schema. Add direct validator interoperability, validator-free `typed()` events, propertyless events, inferred client/server factories, normalized validation failures, and validated provider outputs. Server `track()` calls now fail closed with the new `invalid_options` code when the options argument contains an unrecognized key, and Proxy replay no longer re-runs Standard Schema validators against already-validated client output. This removes the legacy event helper types and client singleton convenience API; see the [Standard Schema migration guide](https://trakoo.co/docs/guides/standard-schema-migration). diff --git a/docs/superpowers/plans/2026-07-22-standard-schema-event-definitions.md b/docs/superpowers/plans/2026-07-22-standard-schema-event-definitions.md new file mode 100644 index 0000000..1fd49b2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-standard-schema-event-definitions.md @@ -0,0 +1,1147 @@ +# Standard Schema Event Definitions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace trakoo's phantom event types with a required runtime event registry that accepts Standard Schema validators directly, retains a validator-free type-only path, and infers client/server tracking without event generics. + +**Architecture:** `defineEvents()` augments a literal definition object with a private emitted-name registry, while `typed()` and `noProperties()` provide branded Standard Typed markers. Named input/output maps drive `track()`; a shared resolver normalizes schema output or applies drop/throw before either adapter routes an event. + +**Tech Stack:** TypeScript 5.5, `@standard-schema/spec` 1.1, Zod 4 integration fixture, Vitest 2, Vite 6, vite-plugin-dts, pnpm 9, Biome, Blume + +## Global Constraints + +- `events` is required by both factories; there is no arbitrary-event fallback. +- Schema users need no trakoo generics, `as const`, `satisfies`, type aliases, or validator adapters. +- Type-only shapes appear once in `typed()`; propertyless events use `noProperties()`. +- `typed()` accepts ordinary interfaces and rejects primitives, arrays, and functions without requiring an index signature. +- Standard Schema input drives `track()` and successful output reaches every provider. +- `track()` always returns `Promise`; delivery starts after validation. +- Validation defaults to `drop` everywhere; `throw` is opt-in. +- Errors and default logs never retain or emit the complete input payload. +- `@standard-schema/spec` is a regular dependency via type-only imports; Zod is development-only. +- Client instances are fresh and registry-bound; remove the singleton and untyped convenience functions. +- Event environment scoping, JSON Schema, generated docs, and validator-specific adapters are out of scope. +- Preserve provider routing, context enrichment, failure isolation, and serialization. + +--- + +## File Map + +- `src/core/events/schema.ts`: branded markers, Standard guards, inference helpers. +- `src/core/events/registry.ts`: definitions, `defineEvents()`, lookup, duplicate detection, input/output maps. +- `src/core/events/validation.ts`: config, normalized errors, validation, drop/throw handling. +- `src/adapters/client/browser-analytics.ts`, `src/client.ts`: registry-bound client API. +- `src/adapters/server/server-analytics.ts`, `src/server.ts`: registry-bound server API. +- `src/providers/proxy/server.ts`: raw proxy replay through runtime registry validation. +- `src/{index,client/index,server/index}.ts`: final public exports. +- `test/events.test.ts`, `test/event-validation.test.ts`: core contract tests. +- `test/{client-analytics,server-analytics,provider-routing,proxy-server}.test.ts`: integration migration. +- `test/standard-schema-integration.test.ts`: real Zod inference/transformation. +- `scripts/verify-package.mjs`: clean packed-consumer verification. +- `readme.md`, `www/content/docs/**/*.mdx`: complete API/documentation migration. +- `.changeset/standard-schema-events.md`: breaking release metadata. + +--- + +### Task 1: Standard Typed Markers and Runtime Registry + +**Files:** +- Create: `src/core/events/schema.ts` +- Create: `src/core/events/registry.ts` +- Modify: `src/core/events/index.ts` +- Replace: `test/events.test.ts` +- Modify: `package.json`, `pnpm-lock.yaml` + +**Interfaces:** +- Consumes: `EventCategory`; Standard Typed/Schema v1. +- Produces: `TypeMarker`, `NoPropertiesMarker`, `typed()`, `noProperties()`, `InferMarker`, `defineEvents()`, `EventRegistry`, `EventName`, `EventInputMap`, `EventOutputMap`, client/server track tuple types. + +- [ ] **Step 1: Add dependencies** + +```bash +pnpm add @standard-schema/spec@^1.1.0 +pnpm add -D zod@^4.4.3 +``` + +Expected: Standard Schema is in `dependencies`; Zod is in `devDependencies`. + +- [ ] **Step 2: Write failing core tests** + +Replace `test/events.test.ts` with tests based on: + +```typescript +interface ClickProperties { buttonId: string; location?: string } + +const events = defineEvents({ + buttonClicked: { + name: "button_clicked", + category: "engagement", + properties: typed(), + }, + sessionStarted: { + name: "session_started", + category: "user", + properties: noProperties(), + }, +}); + +expectTypeOf>() + .toEqualTypeOf<"button_clicked" | "session_started">(); +expectTypeOf["button_clicked"]>() + .toEqualTypeOf(); +expectTypeOf["button_clicked"]>() + .toEqualTypeOf(); +expectTypeOf["session_started"]>() + .toEqualTypeOf(); +expectTypeOf<(typeof events)["buttonClicked"]["category"]>() + .toEqualTypeOf<"engagement">(); + +// @ts-expect-error primitive +typed(); +// @ts-expect-error array +typed(); +// @ts-expect-error function +typed<() => void>(); +``` + +Add runtime tests that duplicate emitted names throw `/duplicate event name/i` +and the private registry metadata is non-enumerable. + +- [ ] **Step 3: Confirm failure** + +```bash +pnpm vitest run test/events.test.ts +pnpm typecheck +``` + +Expected: FAIL because the new helpers do not exist. + +- [ ] **Step 4: Implement `schema.ts`** + +Use these public contracts: + +```typescript +import type { StandardSchemaV1, StandardTypedV1 } from "@standard-schema/spec"; + +declare const typeMarkerBrand: unique symbol; +declare const noPropertiesBrand: unique symbol; + +export type PropertyObject = T extends readonly unknown[] + ? never + : T extends (...args: never[]) => unknown + ? never + : T extends object ? T : never; + +type InvalidPropertyArguments = PropertyObject extends never + ? [error: "typed() requires a non-array, non-callable object shape"] + : []; + +export interface TypeMarker extends StandardTypedV1 { + readonly kind: "type"; + readonly [typeMarkerBrand]: T; +} + +export interface NoPropertiesMarker + extends StandardTypedV1> { + readonly kind: "none"; + readonly [noPropertiesBrand]: true; +} + +export function typed( + ...invalid: InvalidPropertyArguments +): TypeMarker; +export function noProperties(): NoPropertiesMarker; + +export type InferMarker = T extends TypeMarker + ? TValue + : Record; + +export type EventProperties = + | TypeMarker + | NoPropertiesMarker + | StandardSchemaV1; +``` + +Return frozen marker objects containing +`"~standard": { version: 1, vendor: "trakoo" }` and no `validate`. Add +`isTypeMarker`, `isNoPropertiesMarker`, and `isStandardSchema` guards. The +private brand is compile-time-only; `kind` is the runtime discriminator. + +- [ ] **Step 5: Implement `registry.ts`** + +Use an augmented literal object, not a wrapper: + +```typescript +export interface RuntimeEventDefinition< + TName extends string = string, + TProperties extends EventProperties = EventProperties, +> { + readonly name: TName; + readonly category: EventCategory; + readonly properties: TProperties; +} + +export type EventDefinitions = Record; +const registryBrand: unique symbol = Symbol("trakoo.eventRegistry"); +export type EventRegistry = T & { + readonly [registryBrand]: ReadonlyMap; +}; + +export function defineEvents( + definitions: T, +): EventRegistry; +``` + +Build a name map, throw on duplicates, and attach it with +`Object.defineProperty` using `enumerable: false` and `writable: false`. Export +`getEventDefinition()` for internal runtime lookup. + +Add compile-time fixtures proving that Standard Schema definitions whose input +or output is a primitive, array, or function are rejected by `defineEvents()`. + +Build `EventInputMap`/`EventOutputMap` using +`StandardTypedV1.InferInput/InferOutput`, keyed by emitted `name`. Map +`NoPropertiesMarker` to input `undefined` and output `Record`. +Use these intermediate types so the registry's private symbol never leaks into +the event-definition union: + +```typescript +type RegistryDefinitions> = + R extends EventRegistry ? T : never; +type EventDefinitionOf> = RegistryDefinitions[ + keyof RegistryDefinitions +]; + +export type EventName> = + EventDefinitionOf["name"]; +type DefinitionForName< + R extends EventRegistry, + N extends EventName, +> = Extract, { name: N }>; +type PropertiesForName< + R extends EventRegistry, + N extends EventName, +> = DefinitionForName["properties"]; + +type InputFor = + TProperties extends NoPropertiesMarker + ? undefined + : StandardTypedV1.InferInput; +type OutputFor = + TProperties extends NoPropertiesMarker + ? Record + : StandardTypedV1.InferOutput; + +export type EventInputMap> = { + [N in EventName]: InputFor>; +}; + +export type EventOutputMap> = { + [N in EventName]: OutputFor>; +}; +``` + +Then define readable exported tuple helpers: + +```typescript +export type ClientTrackArgs< + R extends EventRegistry, + N extends EventName, +> = EventInputMap[N] extends undefined + ? [eventName: N] + : [eventName: N, properties: EventInputMap[N]]; + +export type ServerTrackArgs< + R extends EventRegistry, + N extends EventName, + O, +> = EventInputMap[N] extends undefined + ? [eventName: N] | [eventName: N, options: O] + : [eventName: N, properties: EventInputMap[N], options?: O]; +``` + +- [ ] **Step 6: Export and verify core registry** + +Export new APIs from `src/core/events/index.ts`, temporarily retaining legacy +helpers until Task 5. + +```bash +pnpm vitest run test/events.test.ts +pnpm typecheck +pnpm lint +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add package.json pnpm-lock.yaml src/core/events/schema.ts src/core/events/registry.ts src/core/events/index.ts test/events.test.ts +git commit -m "feat: add Standard Schema event registry" +``` + +--- + +### Task 2: Shared Validation and Failure Policy + +**Files:** +- Create: `src/core/events/validation.ts` +- Create: `test/event-validation.test.ts` +- Modify: `src/core/events/index.ts` + +**Interfaces:** +- Consumes: registry lookup and marker guards. +- Produces: `ValidationConfig`, `AnalyticsValidationError`, `ResolvedEvent`, `resolveEvent()`. + +- [ ] **Step 1: Write failing validation tests** + +Use local Standard Schema fixtures with these validate results: + +```typescript +const success = { value: { amount: 49 } }; +const failure = { issues: [{ message: "invalid", path: ["amount"] }] }; +const asyncSuccess = Promise.resolve({ value: { amount: 49 } }); +``` + +Cover sync/async success, transforms, returned issues, thrown/rejected +validators, primitive/null/array output, unknown names, type-marker top-level +object checks, propertyless normalization/rejection, default drop, opt-in throw, +`onError` exactly once, throwing/rejected callbacks, sanitized debug logging, +and absence of input/payload fields on errors. Use a deferred async validator to +prove resolution does not occur before validation finishes. + +For a transformed fixture, statically assert that a successful resolution keeps +the selected output: + +```typescript +const resolved = await resolveEvent( + events, + "purchase_completed", + { amount: "49" }, + true, + undefined, + false, +); +expectTypeOf(resolved?.properties).toEqualTypeOf< + { amount: number } | undefined +>(); +``` + +- [ ] **Step 2: Confirm failure** + +```bash +pnpm vitest run test/event-validation.test.ts +``` + +Expected: FAIL because validation exports do not exist. + +- [ ] **Step 3: Implement error contracts** + +```typescript +export type AnalyticsValidationErrorCode = + | "unknown_event" + | "invalid_properties" + | "validator_failure" + | "invalid_output"; + +export interface ValidationConfig { + readonly onFailure?: "drop" | "throw"; + readonly onError?: (error: AnalyticsValidationError) => void; +} + +export class AnalyticsValidationError extends Error { + readonly name = "AnalyticsValidationError"; + constructor( + readonly code: AnalyticsValidationErrorCode, + readonly eventName: string, + readonly issues: readonly NormalizedValidationIssue[] = [], + ) { + super(`Analytics event ${eventName} failed: ${code}`); + } +} +``` + +Normalize paths safely, including symbols and `{ key }` segments. Do not store +the validator exception as `cause` because it may retain input. + +- [ ] **Step 4: Implement `resolveEvent()`** + +```typescript +export interface ResolvedEvent< + R extends EventRegistry, + N extends EventName, +> { + readonly name: N; + readonly category: EventCategory; + readonly properties: EventOutputMap[N]; +} + +export async function resolveEvent< + R extends EventRegistry, + N extends EventName, +>( + registry: R, + eventName: N, + input: unknown, + inputProvided: boolean, + validation: ValidationConfig | undefined, + debug: boolean, +): Promise | undefined>; +``` + +Lookup, use `inputProvided` to distinguish omission from explicit `undefined`, +handle propertyless/type/schema definitions, await validation, and +accept only non-null non-array object output. Invoke `onError` once; contain +sync throws and rejected thenables; return `undefined` for drop or throw the +normalized error for strict mode. Debug fallback logs only `{ code, eventName, +paths }`, never raw messages or input. + +- [ ] **Step 5: Export, verify, and commit** + +```bash +pnpm vitest run test/event-validation.test.ts test/events.test.ts +pnpm typecheck +pnpm lint +git add src/core/events/validation.ts src/core/events/index.ts test/event-validation.test.ts +git commit -m "feat: validate Standard Schema event properties" +``` + +Expected: all commands PASS before commit. + +--- + +### Task 3: Registry-Bound Client Analytics + +**Files:** +- Modify: `src/core/events/types.ts` +- Modify: `src/adapters/client/browser-analytics.ts` +- Replace: `src/client.ts` +- Modify: `src/client/index.ts` +- Modify: `test/client-analytics.test.ts`, `test/client.test.ts` +- Modify: client sections of `test/provider-routing.test.ts` + +**Interfaces:** +- Consumes: registry maps/tuples, `TypeMarker`, marker inference, `resolveEvent()`. +- Produces: inferred `ClientAnalyticsConfig`, fresh `BrowserAnalytics`, strict `track()`. + +- [ ] **Step 1: Migrate client tests before implementation** + +Define registries with `defineEvents()` and pass `events` to every client +factory. Include `page_viewed`, `button_clicked`, `test_event`, reset-related +propertyless events, and all client routing names. Remove singleton reset calls. +Add assertions for fresh instances, definition categories, transformed output, +disabled short-circuit, drop/throw, propertyless calls, and inferred traits. +Use `@ts-expect-error` for unknown names, missing/extra properties, and a second +argument on a propertyless event. Route a transformed event to two providers and +assert both receive the same normalized output. Verify a disabled invalid event +bypasses lookup, `onError`, and provider delivery. + +Update `test/client.test.ts` to assert singleton helpers and the +`createAnalytics` alias are absent. + +- [ ] **Step 2: Confirm failure** + +```bash +pnpm vitest run test/client-analytics.test.ts test/client.test.ts test/provider-routing.test.ts +pnpm typecheck +``` + +Expected: FAIL on missing `events` support and stale singleton exports. + +- [ ] **Step 3: Widen user-trait constraints correctly** + +Change `UserContext` and `EventContext` constraints from +`Record` to `object`, retaining the record default. Cast only +when passing traits or ordinary-interface event properties to the provider +transport API. + +Make `BaseEvent` generic so successful schema output remains selected until that +transport boundary: + +```typescript +export interface BaseEvent< + TProperties extends object = Record, +> { + category: EventCategory; + action: string; + timestamp?: number; + userId?: string; + sessionId?: string; + properties?: TProperties; +} +``` + +- [ ] **Step 4: Refactor `BrowserAnalytics`** + +```typescript +export class BrowserAnalytics< + TRegistry extends EventRegistry, + TUserTraits extends object = Record, +> { + async track>( + ...args: ClientTrackArgs + ): Promise; +} +``` + +Store registry, validation config, debug, and `enabled !== false`. Short-circuit +before initialization when disabled. Pass `args.length > 1` as +`inputProvided` so a JavaScript call such as `track("session_started", +undefined)` is rejected rather than treated as omission. Call `resolveEvent`; +return on drop; build +`BaseEvent[TName]>` from resolved +name/category/properties. Remove category derivation. Preserve browser/session +context and provider routing, widening to the provider's existing `BaseEvent` +transport type only in the call that invokes each provider. + +- [ ] **Step 5: Replace the client factory and exports** + +```typescript +export interface ClientAnalyticsConfig { + readonly events: R; + readonly userTraits?: M; + readonly providers?: ProviderConfigOrProvider[]; + readonly validation?: ValidationConfig; + readonly debug?: boolean; + readonly enabled?: boolean; +} +``` + +Constrain `R` to an event registry and `M` to `TypeMarker | undefined` +in the real declaration. Infer `InferMarker` in the return type. Always +default `providers` to an empty array and return a fresh initialized +`BrowserAnalytics`. Remove singleton state, getter, reset hook, alias, and +module-level track/identify/page/reset/flush functions. + +- [ ] **Step 6: Verify and commit** + +```bash +pnpm vitest run test/client-analytics.test.ts test/client.test.ts test/provider-routing.test.ts +pnpm typecheck +pnpm lint +git add src/core/events/types.ts src/adapters/client/browser-analytics.ts src/client.ts src/client/index.ts test/client-analytics.test.ts test/client.test.ts test/provider-routing.test.ts +git commit -m "feat: bind client analytics to event registries" +``` + +Expected: client tests and typecheck PASS before commit. + +--- + +### Task 4: Registry-Bound Server Analytics and Proxy Replay + +**Files:** +- Modify: `src/adapters/server/server-analytics.ts` +- Modify: `src/server.ts`, `src/server/index.ts` +- Modify: `src/providers/proxy/server.ts` +- Modify: `test/server-analytics.test.ts`, `test/server.test.ts` +- Modify: `test/proxy-server.test.ts` +- Modify: server sections of `test/provider-routing.test.ts` + +**Interfaces:** +- Consumes: registry maps/tuples, marker inference, `resolveEvent()`. +- Produces: `ServerAnalyticsConfig`, `ServerTrackOptions`, strict server tracking, validated raw proxy replay. + +- [ ] **Step 1: Migrate server/proxy tests first** + +Replace phantom definitions with `defineEvents()` and add `events` to every +server factory. Cover strict unknown names/properties, definition categories, +schema transformations, disabled mode, drop/throw, and these propertyless forms: + +```typescript +await analytics.track("session_started"); +await analytics.track("session_started", { userId: "user_123" }); +// @ts-expect-error no undefined properties placeholder +await analytics.track("session_started", undefined); +// @ts-expect-error properties are forbidden +await analytics.track("session_started", { unexpected: true }); +``` + +Add the routing registry to all server sections of +`test/provider-routing.test.ts`. Give proxy tests a registry containing each +replayed track name and assert an unknown raw name follows validation policy. +Create the server instance with `userTraits: typed()` and verify raw +proxy `identify` traits and enriched user context cross only the JSON boundary +cast while the public server API remains strictly typed. + +- [ ] **Step 2: Confirm failure** + +```bash +pnpm vitest run test/server-analytics.test.ts test/server.test.ts test/proxy-server.test.ts test/provider-routing.test.ts +pnpm typecheck +``` + +Expected: FAIL because server/proxy types still use the event-map API. + +- [ ] **Step 3: Refactor `ServerAnalytics`** + +```typescript +export interface ServerTrackOptions { + readonly userId?: string; + readonly sessionId?: string; + readonly context?: EventContext; + readonly user?: UserContext; +} + +export class ServerAnalytics< + TRegistry extends EventRegistry, + TUserTraits extends object = Record, +> { + async track>( + ...args: ServerTrackArgs< + TRegistry, + TName, + ServerTrackOptions + > + ): Promise; +} +``` + +Store registry/validation/debug/enabled. Short-circuit when disabled, retain the +initialized check, resolve before context construction, use definition category, +and remove category derivation. Construct +`BaseEvent[TName]>`, widening ordinary-interface output +only in the call to the shared provider transport. For propertyless events, omit +the properties slot and accept server options directly in argument two. Reject +an explicit `undefined` second argument at runtime. To parse the overload, look +up the runtime definition: for `noProperties()`, treat an object in argument two +as options and set `inputProvided` to `false`. If argument two is present but is +`undefined`, null, an array, a non-object value, or contains keys outside +`userId`, `sessionId`, `context`, and `user`, route `invalid_properties` through +the shared failure policy. For other definitions, use argument two as input, +argument three as options, and set `inputProvided` from `args.length > 1`. +Unknown definitions still flow into `resolveEvent()` and its configured failure +policy. + +- [ ] **Step 4: Replace server factory inference** + +Mirror the client config with required registry and optional branded +`userTraits`. Infer registry and `InferMarker` without explicit factory +generics, default `providers` to an empty array, create a fresh instance, and +initialize it as today. + +- [ ] **Step 5: Adapt proxy replay at its JSON boundary** + +Parameterize proxy ingestion by registry. Keep the public `track()` strict and +cast only raw JSON arguments: + +```typescript +await analytics.track( + event.event.action as EventName, + event.event.properties as never, + { + userId: event.event.userId, + sessionId: event.event.sessionId, + context: enrichedContext, + }, +); +``` + +Do not add an untyped public tracking method; runtime lookup/validation must +still reject or drop unknown proxy events. Parameterize `ingestProxyEvents()` +and `createProxyHandler()` by both registry and inferred traits. Keep contained +casts at the JSON boundary for `event.traits as TUserTraits` and enriched +`EventContext`; do not widen `ServerAnalytics.identify()` or its +context types to make raw proxy data compile. + +- [ ] **Step 6: Verify and commit** + +```bash +pnpm test +pnpm typecheck +pnpm lint +git add src/adapters/server/server-analytics.ts src/server.ts src/server/index.ts src/providers/proxy/server.ts test/server-analytics.test.ts test/server.test.ts test/proxy-server.test.ts test/provider-routing.test.ts +git commit -m "feat: bind server analytics to event registries" +``` + +Expected: all code tests, typecheck, and lint PASS before commit. + +--- + +### Task 5: Final Public API, Zod Integration, and Packed Consumer + +**Files:** +- Modify: `src/core/events/index.ts`, `src/core/events/types.ts` +- Modify: `src/index.ts`, `src/client/index.ts`, `src/server/index.ts` +- Modify: `test/client.test.ts`, `test/server.test.ts` +- Create: `test/standard-schema-integration.test.ts` +- Create: `test/type-diagnostics.test.ts` +- Create: `test/fixtures/invalid-event-usage.ts` +- Create: `scripts/verify-package.mjs` +- Modify: `package.json`, `tsconfig.json` + +**Interfaces:** +- Consumes: completed registry-bound adapters. +- Produces: final breaking exports, direct Zod proof, packed-consumer verification. + +- [ ] **Step 1: Write failing Zod and export tests** + +Create a direct integration with no adapter: + +```typescript +const events = defineEvents({ + purchaseCompleted: { + name: "purchase_completed", + category: "conversion", + properties: z.object({ + orderId: z.string(), + amount: z.string().transform(Number), + }), + }, +}); + +expectTypeOf["purchase_completed"]>() + .toEqualTypeOf<{ orderId: string; amount: string }>(); +expectTypeOf["purchase_completed"]>() + .toEqualTypeOf<{ orderId: string; amount: number }>(); +``` + +Track `{ orderId: "order_1", amount: "49" }` through a server mock and expect +provider properties `{ orderId: "order_1", amount: 49 }`. Export tests assert +root helpers/error are present and old event helpers/client singleton functions +are absent. + +Add a deliberately invalid standalone consumer fixture with a misspelled event +name and incorrect properties. In `test/type-diagnostics.test.ts`, run the +pinned workspace compiler with `--pretty false`, assert it fails, assert the +output names the invalid call and expected public event/property types, keep the +diagnostic below 30 lines, and assert internal helpers such as +`PropertiesForName` and `DefinitionForName` do not appear. Use `pnpm.cmd` on +Windows and `pnpm` elsewhere. Add this one fixture path to `tsconfig.json`'s +`exclude` list so the intentional errors do not break the normal project +typecheck; the diagnostic test compiles it explicitly with equivalent strict, +Bundler-resolution flags. + +- [ ] **Step 2: Confirm stale public API failure** + +```bash +pnpm vitest run test/standard-schema-integration.test.ts test/type-diagnostics.test.ts test/client.test.ts test/server.test.ts +pnpm build +``` + +Expected: export checks/build FAIL while legacy declarations remain. + +- [ ] **Step 3: Remove all competing APIs** + +Delete declarations and exports for: + +```text +CreateEventDefinition +EventCollection +ExtractEventNames +ExtractEventPropertiesFromCollection +EventMapFromCollection +EventDefinition +ExtractEventName +ExtractEventProperties +``` + +Retain transport/provider types, including the low-level `AnyEventName` and +`AnyEventProperties` aliases. Export `defineEvents`, `typed`, +`noProperties`, maps, configs, and `AnalyticsValidationError` from appropriate +entry points. Root runtime imports must remain environment-neutral. + +- [ ] **Step 4: Add packed-consumer verification** + +Add `"verify:package": "node scripts/verify-package.mjs"` to `package.json`. +Implement `scripts/verify-package.mjs` with this complete flow: + +```javascript +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const consumerDirectory = mkdtempSync( + join(tmpdir(), "trakoo-package-consumer-"), +); +let tarballPath; + +const run = (command, args, cwd = root) => + execFileSync(command, args, { cwd, encoding: "utf8", stdio: "pipe" }); + +const consumerSource = String.raw` +import { defineEvents, noProperties, typed } from "trakoo"; +import { createClientAnalytics } from "trakoo/client"; + +const events = defineEvents({ + clicked: { + name: "clicked", + category: "engagement", + properties: typed<{ id: string }>(), + }, + started: { + name: "started", + category: "user", + properties: noProperties(), + }, +}); + +const analytics = createClientAnalytics({ events, providers: [] }); +analytics.track("clicked", { id: "cta" }); +analytics.track("started"); +`; + +try { + run("pnpm", ["build"]); + const packResult = JSON.parse(run("npm", ["pack", "--json"])); + tarballPath = resolve(root, packResult[0].filename); + + run("npm", ["init", "-y"], consumerDirectory); + run( + "npm", + ["install", "--ignore-scripts", tarballPath], + consumerDirectory, + ); + + writeFileSync(join(consumerDirectory, "consumer.ts"), consumerSource); + writeFileSync( + join(consumerDirectory, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + strict: true, + noEmit: true, + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + }, + include: ["consumer.ts"], + }, + null, + 2, + ), + ); + run( + process.execPath, + [ + resolve(root, "node_modules/typescript/bin/tsc"), + "--project", + join(consumerDirectory, "tsconfig.json"), + ], + consumerDirectory, + ); + + const installedManifest = JSON.parse( + readFileSync( + join(consumerDirectory, "node_modules/trakoo/package.json"), + "utf8", + ), + ); + if (!installedManifest.dependencies?.["@standard-schema/spec"]) { + throw new Error("packed trakoo is missing @standard-schema/spec dependency"); + } + + const concreteValidators = ["zod", "valibot", "arktype"]; + for (const field of [ + "dependencies", + "optionalDependencies", + "peerDependencies", + ]) { + for (const packageName of concreteValidators) { + if (installedManifest[field]?.[packageName]) { + throw new Error(`packed trakoo declares concrete validator ${packageName}`); + } + } + } + + const rootBundle = readFileSync( + join(consumerDirectory, "node_modules/trakoo/dist/index.js"), + "utf8", + ); + for (const prohibitedImport of [ + ...concreteValidators, + "posthog-js", + "posthog-node", + "@openpanel/sdk", + "@openpanel/web", + ]) { + if (rootBundle.includes(prohibitedImport)) { + throw new Error(`root bundle includes ${prohibitedImport}`); + } + } + + // Prove root event helpers load without optional provider packages present. + run("npm", ["prune", "--omit=optional"], consumerDirectory); + writeFileSync( + join(consumerDirectory, "consumer.ts"), + String.raw` +import { defineEvents, typed } from "trakoo"; + +defineEvents({ + checked: { + name: "checked", + category: "test", + properties: typed<{ value: string }>(), + }, +}); +`, + ); + run( + process.execPath, + [ + resolve(root, "node_modules/typescript/bin/tsc"), + "--project", + join(consumerDirectory, "tsconfig.json"), + ], + consumerDirectory, + ); + run( + process.execPath, + ["--input-type=module", "--eval", 'await import("trakoo")'], + consumerDirectory, + ); +} finally { + if (tarballPath) rmSync(tarballPath, { force: true }); + rmSync(consumerDirectory, { recursive: true, force: true }); +} +``` + +Do not install Zod in this fixture. The initial install verifies the full client +declaration path; pruning optional provider dependencies and smoke-importing the +root separately verifies the environment-neutral, validator-free root path. + +- [ ] **Step 5: Verify and commit** + +```bash +pnpm test +pnpm typecheck +pnpm lint +pnpm build +pnpm verify:package +git add package.json tsconfig.json src/core/events/index.ts src/core/events/types.ts src/index.ts src/client/index.ts src/server/index.ts test/client.test.ts test/server.test.ts test/standard-schema-integration.test.ts test/type-diagnostics.test.ts test/fixtures/invalid-event-usage.ts scripts/verify-package.mjs +git commit -m "feat: publish the Standard Schema event API" +``` + +Expected: every command PASS before commit; no concrete validator import exists +in `dist`. + +--- + +### Task 6: README and Core Documentation + +**Files:** +- Modify: `readme.md` +- Modify: `www/content/docs/(Getting Started)/index.mdx` +- Modify: `www/content/docs/(Getting Started)/installation.mdx` +- Modify: `www/content/docs/(Getting Started)/quick-start.mdx` +- Modify: `www/content/docs/core-concepts/client-vs-server.mdx` +- Modify: `www/content/docs/core-concepts/events.mdx` +- Modify: `www/content/docs/core-concepts/identifying-users.mdx` +- Modify: `www/content/docs/core-concepts/index.mdx` +- Modify: `www/content/docs/core-concepts/providers.mdx` +- Modify: `www/content/docs/core-concepts/type-safety.mdx` + +**Interfaces:** +- Consumes: final API/failure behavior. +- Produces: canonical type-only, schema-backed, propertyless, factory, trait, and failure documentation. + +- [ ] **Step 1: Record failing legacy checks** + +```bash +rg -n 'CreateEventDefinition|EventCollection|as const satisfies|properties: \{\} as|create(Client|Server)Analytics<' readme.md www/content/docs/'(Getting Started)' www/content/docs/core-concepts +``` + +Expected: output identifies every legacy example. + +- [ ] **Step 2: Replace definition examples** + +Use this lightweight canonical form: + +```typescript +export const appEvents = defineEvents({ + buttonClicked: { + name: "button_clicked", + category: "engagement", + properties: typed<{ buttonId: string; location: string }>(), + }, + sessionStarted: { + name: "session_started", + category: "user", + properties: noProperties(), + }, +}); +``` + +Use direct Zod only where validation is being taught: + +```typescript +properties: z.object({ + orderId: z.string(), + amount: z.coerce.number().positive(), +}) +``` + +Explain input versus provider output and that Standard Schema is an interface, +not a required validator. + +- [ ] **Step 3: Replace factory, trait, and failure examples** + +Every factory receives `events: appEvents` and no event generic. Custom traits +use `userTraits: typed()`. Strict examples use: + +```typescript +validation: { + onFailure: "throw", + onError: (error) => reportValidationFailure(error), +} +``` + +Document default drop, sanitized debug output, and no payload retention. Remove +module-level client helper usage; applications import their owned instance. +Document that concurrent async-validation calls are not delivery-ordered and +that initialization/provider errors retain their existing behavior. + +- [ ] **Step 4: Correct root import guidance** + +Describe root `trakoo` as shared types plus environment-neutral event helpers. +Factories/providers remain on client/server subpaths. + +- [ ] **Step 5: Verify and commit core docs** + +```bash +rg -n 'CreateEventDefinition|EventCollection|as const satisfies|properties: \{\} as|create(Client|Server)Analytics<' readme.md www/content/docs/'(Getting Started)' www/content/docs/core-concepts +pnpm build:docs +git add readme.md www/content/docs/'(Getting Started)' www/content/docs/core-concepts +git commit -m "docs: migrate core guides to Standard Schema events" +``` + +Expected: `rg` has no output/exit 1 and docs build PASS before commit. + +--- + +### Task 7: Framework/Provider Docs, Migration Guide, and Release Verification + +**Files:** +- Modify: `www/content/docs/guides/index.mdx`, `nextjs.mdx`, `sveltekit.mdx`, `meta.ts` +- Create: `www/content/docs/guides/standard-schema-migration.mdx` +- Modify: `www/content/docs/providers/bento.mdx` +- Modify: `www/content/docs/providers/custom.mdx` +- Modify: `www/content/docs/providers/emitkit.mdx` +- Modify: `www/content/docs/providers/index.mdx` +- Modify: `www/content/docs/providers/openpanel.mdx` +- Modify: `www/content/docs/providers/pirsch.mdx` +- Modify: `www/content/docs/providers/posthog.mdx` +- Modify: `www/content/docs/providers/proxy.mdx` +- Modify: `www/content/docs/providers/visitors.mdx` +- Create: `.changeset/standard-schema-events.md` + +**Interfaces:** +- Consumes: final package API and migrated terminology. +- Produces: complete ecosystem docs, breaking migration path, release metadata, verified branch. + +- [ ] **Step 1: Create the migration guide** + +Use frontmatter: + +```markdown +--- +title: Standard Schema migration +description: Migrate legacy typed event collections to trakoo's runtime event registry. +--- +``` + +Include sections `What changed`, `Type-only migration`, `Runtime validation`, +`Validation failures`, and `Client singleton migration`. Each contains literal +before/after code from the approved spec. Add `"standard-schema-migration"` +after `"index"` in `guides/meta.ts`. + +- [ ] **Step 2: Migrate framework guides** + +Shared event modules export the registry value. Client/server modules import +that value and pass `events: appEvents`; they do not import `AppEvents` types. +Use `typed()` for primary examples and link to runtime validation guidance. + +- [ ] **Step 3: Migrate every provider example** + +Import a shared registry to keep examples focused: + +```typescript +import { appEvents } from "@/lib/events"; + +const analytics = createClientAnalytics({ + events: appEvents, + providers: [provider], +}); +``` + +Use the server equivalent where appropriate. Proxy docs pass the same registry +to client proxy analytics and ingesting server analytics. + +- [ ] **Step 4: Add breaking changeset** + +Create `.changeset/standard-schema-events.md`: + +```markdown +--- +"trakoo": major +--- + +Replace type-asserted event collections with runtime `defineEvents()` registries based on Standard Schema. Add direct validator interoperability, validator-free `typed()` events, propertyless events, inferred client/server factories, normalized validation failures, and validated provider outputs. This removes the legacy event helper types and client singleton convenience API; see the [Standard Schema migration guide](https://trakoo.co/docs/guides/standard-schema-migration). +``` + +The Changesets release workflow carries this linked note into `CHANGELOG.md`; +do not hand-edit generated changelog content before the release is published. + +- [ ] **Step 5: Run stale API checks** + +Run a fail-closed, line-specific audit over `src`, `test`, `readme.md`, and +`www/content`; do not obfuscate the historical examples or exclude whole files. + +- Scan all eight removed type-helper names with word boundaries: + `CreateEventDefinition`, `EventCollection`, `ExtractEventNames`, + `ExtractEventPropertiesFromCollection`, `EventMapFromCollection`, + `EventDefinition`, `ExtractEventName`, and `ExtractEventProperties`. Every + match must be one of the exact `@ts-expect-error` assertions/imports in + `test/client.test.ts` or an exact migration-before line in + `www/content/docs/guides/standard-schema-migration.mdx`; any other line fails + the audit. +- Scan `as const satisfies`, `properties: {} as`, and + `create(Client|Server)Analytics<`. Allow only the exact migration-before + lines, the current generic factory declarations in `src/client.ts` and + `src/server.ts`, and the internal propertyless normalization cast in + `src/core/events/validation.ts`; any other line fails the audit. +- Scan `getAnalytics(`, `resetAnalyticsInstance`, `createAnalytics as`, and + `createClientAnalytics as createAnalytics`. This singleton/alias scan remains + a zero-match check (exit code 1). + +Expected: both allowlisted scans contain only the exact lines above, and the +singleton/alias scan has no output. New or changed matches fail closed and must +be reviewed line by line. + +- [ ] **Step 6: Run full verification** + +```bash +pnpm test +pnpm typecheck +pnpm lint +pnpm build +pnpm build:docs +pnpm verify:package +git diff --check origin/main... +``` + +Expected: every command PASS and diff check prints nothing. + +- [ ] **Step 7: Commit release docs** + +```bash +git add www/content/docs/guides www/content/docs/providers .changeset/standard-schema-events.md +git commit -m "docs: add Standard Schema migration guidance" +``` + +- [ ] **Step 8: Verify clean completion** + +```bash +pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm build:docs && pnpm verify:package +git diff --check origin/main... +git status --short +``` + +Expected: all verification PASS, no diff-check output, and a clean worktree. diff --git a/docs/superpowers/specs/2026-07-22-standard-schema-event-definitions-design.md b/docs/superpowers/specs/2026-07-22-standard-schema-event-definitions-design.md new file mode 100644 index 0000000..6aff3f6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-standard-schema-event-definitions-design.md @@ -0,0 +1,502 @@ +# Standard Schema Event Definitions Design + +**Date:** 2026-07-22 +**Status:** Approved for specification review + +## Goal + +Replace trakoo's type-asserted event collections with a runtime event registry +based on the Standard Schema family. The new model must preserve trakoo's +compile-time event safety, optionally add runtime validation and transformation, +and remove the explicit generics and `as const satisfies` ceremony required by +the current API. + +This is an intentionally breaking event-definition release. Client/server event +scoping will build on this foundation in a later release and is not part of this +work. + +## Design Principles + +- Use Standard Typed and Standard Schema as structural contracts rather than + coupling trakoo to a validation library. +- Make runtime validation available but not mandatory. +- Keep every event's property shape in one source of truth. +- Infer event names and property types from runtime definitions without factory + generics or exported type aliases. +- Validate once before provider routing and give every provider the same + normalized output. +- Keep analytics resilient by dropping invalid events by default. +- Do not let validation, reporting, or default logs expose complete event + payloads. + +## Public Event API + +The root `trakoo` entry point will export the environment-neutral runtime +helpers `defineEvents()`, `typed()`, and `noProperties()`. It will continue to +export the shared event types. Importing these helpers must remain browser-safe +and must not pull in client providers, server providers, or any validation +library. + +### Schema-backed events + +A Standard Schema-compatible value is used directly as the `properties` +definition: + +```typescript +import { defineEvents } from "trakoo"; +import { z } from "zod"; + +export const appEvents = defineEvents({ + purchaseCompleted: { + name: "purchase_completed", + category: "conversion", + properties: z.object({ + orderId: z.string(), + amount: z.number().positive(), + }), + }, +}); +``` + +The properties schema supplies both the input type accepted by `track()` and +the output type sent to providers. If a schema transforms or sanitizes its +input, providers receive the successful output and never the original input. + +Trakoo integrates only with the Standard Schema interface. Recent compatible +versions of Zod, Valibot, ArkType, and other implementations require no trakoo +adapter or factory configuration. + +### Type-only events + +Users who do not want a validation library use `typed()`: + +```typescript +import { defineEvents, typed } from "trakoo"; + +export const appEvents = defineEvents({ + buttonClicked: { + name: "button_clicked", + category: "engagement", + properties: typed<{ + buttonId: string; + location: string; + }>(), + }, +}); +``` + +`typed()` returns a small Standard Typed-compatible value whose input and +output types are both `T`. It does not implement `validate`, allocate a runtime +schema, or perform runtime validation. It returns a trakoo-branded +`TypeMarker` so APIs that specifically require a type-only marker cannot +accidentally accept a validating Standard Schema. The property-shape constraint +must accept ordinary object interfaces while rejecting primitives, arrays, and +functions; it must not require a string index signature. + +The single `typed()` generic is the only explicit type annotation required +for type-only events. It is unavoidable because no concrete schema exists from +which TypeScript could infer the property shape. + +### Propertyless events + +Events with no properties use a dedicated sentinel rather than an empty object +type: + +```typescript +export const appEvents = defineEvents({ + sessionStarted: { + name: "session_started", + category: "user", + properties: noProperties(), + }, +}); + +analytics.track("session_started"); +``` + +`noProperties()` is a branded Standard Typed-compatible marker whose input is +omitted and whose normalized provider output is an empty object. It gives the +corresponding client event a one-argument `track()` call. Server callers may +pass `ServerTrackOptions` directly as argument two; they never pass an +`undefined` properties placeholder. Client callers that supply argument two, +server callers that supply `undefined` explicitly, and JavaScript or +type-bypassing calls that target a propertyless event through a properties slot +receive `invalid_properties` under the configured validation-failure policy. +Adapters must pass explicit argument-presence metadata to validation so omitted +input is distinguishable from supplied `undefined`. This special case avoids +`typed<{}>()`, whose TypeScript meaning is broader than an exact empty property +object. + +### Registry behavior + +`defineEvents()` accepts an object whose keys organize application source code. +The `name` field remains the emitted event name used by `track()` and analytics +providers. + +`defineEvents()` must: + +- preserve literal event names using const generic inference; +- require every `properties` value to satisfy Standard Typed; +- require inferred input and output values to be object property shapes, except + for the omitted input represented by the branded `noProperties()` sentinel; +- preserve category literals without `as const` or `satisfies` at the call site; +- build or retain enough runtime information for lookup by emitted name; and +- throw a clear initialization error when two definitions use the same emitted + name. + +The event category stored in the definition becomes the category on the +provider event. The adapters will no longer derive categories from event-name +prefixes. + +## Analytics Factory API + +Both factories require the runtime event registry and infer their event map +from it: + +```typescript +import { createClientAnalytics } from "trakoo/client"; + +export const analytics = createClientAnalytics({ + events: appEvents, + providers: [provider], +}); +``` + +```typescript +import { createServerAnalytics } from "trakoo/server"; + +export const serverAnalytics = createServerAnalytics({ + events: appEvents, + providers: [provider], +}); +``` + +The event registry is required. The factories no longer accept an event +collection through an explicit generic, and typed instances do not fall back to +arbitrary event names. + +`createClientAnalytics()` returns a fresh instance bound to the supplied +registry. It must never reuse or cast an instance created with one registry as +an instance for another registry. The existing module-level singleton and its +untyped `getAnalytics()`, `track()`, `identify()`, `pageView()`, `pageLeave()`, +`reset()`, and `flush()` convenience functions, the `createAnalytics` +compatibility alias, and the singleton reset hook are removed. Applications own +and export the typed instance returned by the factory. + +The public type architecture uses named `EventInputMap` and +`EventOutputMap` helpers. Analytics is parameterized by the registry; +`track()` accepts the selected input-map value, while successful event +construction uses the selected output-map value. Only the provider boundary +widens properties to the shared `Record` transport shape. + +Custom user traits also move out of factory generics so event inference is not +lost. Applications that type traits provide an optional Standard Typed marker: + +```typescript +interface UserTraits { + email: string; + plan: "free" | "pro"; +} + +const analytics = createClientAnalytics({ + events: appEvents, + userTraits: typed(), + providers: [provider], +}); +``` + +The `userTraits` option accepts only trakoo's branded `TypeMarker`, not an +arbitrary Standard Typed or Standard Schema value. The marker types +`identify()` and server event user context but is not sent to providers or used +for runtime validation. When omitted, user traits retain the existing open +record default. This keeps both factory calls free of explicit generics. + +After inference, normal use contains no trakoo-specific generics: + +```typescript +analytics.track("purchase_completed", { + orderId: "order_123", + amount: 49, +}); +``` + +The event name determines the accepted property input. Misspelled names, +missing properties, and invalid property values remain compile-time errors. + +## Tracking Data Flow + +Every `track()` call follows the same core sequence on client and server: + +1. Short-circuit immediately when analytics is disabled. +2. Look up the runtime definition by emitted event name. +3. Treat an unknown runtime name as an event-definition failure. This covers + JavaScript callers and TypeScript callers that bypass types. +4. If the properties value implements Standard Schema, call + `properties["~standard"].validate(input)` and await either its synchronous or + asynchronous result. +5. If validation succeeds, require the returned value to be a non-null, + non-array object. +6. Construct the base event using the definition's name, category, and validated + output. A `typed()` definition uses the original input as its output, while + `noProperties()` normalizes its omitted input to an empty object. +7. Apply existing provider method and event routing. +8. Send the same normalized output to every selected provider. + +`track()` consistently returns `Promise` for type-only, propertyless, +synchronously validated, and asynchronously validated events. Provider delivery +never begins until any schema validation has completed. + +Validation happens before any provider receives an event. A failed event is +never partially delivered. Existing provider isolation remains unchanged: one +provider's delivery failure must not prevent other providers from receiving a +valid event. + +Because Standard Schema validators may be asynchronous, concurrent `track()` +calls are not guaranteed to reach providers in call order. This matches the +library's existing parallel, non-queued delivery model and must be documented. + +## Validation Failure Policy + +Client and server analytics share the same resilient default: + +```typescript +const analytics = createClientAnalytics({ + events: appEvents, + providers: [provider], + validation: { + onFailure: "drop", + onError(error) { + // Send sanitized metadata to application observability. + }, + }, +}); +``` + +The public configuration is: + +```typescript +interface ValidationConfig { + onFailure?: "drop" | "throw"; + onError?: (error: AnalyticsValidationError) => void; +} +``` + +`onFailure` defaults to `"drop"` on both client and server. This is consistent +with trakoo's resilience contract and avoids turning a successful business +operation into a failed or retried request because its analytics payload was +invalid. + +Strict behavior is opt-in for tests and workflows that explicitly want it: + +```typescript +const analytics = createServerAnalytics({ + events: appEvents, + providers: [provider], + validation: { onFailure: "throw" }, +}); +``` + +For every event-definition or validation failure: + +1. Construct an `AnalyticsValidationError` without retaining the input payload. +2. Invoke `onError` exactly once when configured. +3. Ignore a returned value from `onError`. If the callback throws or returns a + rejected thenable, contain that failure so reporting cannot change the + configured tracking policy. +4. Under `"drop"`, resolve `track()` without routing the event. +5. Under `"throw"`, reject `track()` with the validation error after the + callback has been invoked. + +When `onError` is absent, production mode is silent. In debug mode, trakoo may +log only sanitized metadata such as the error code, event name, and normalized +paths. It must not log the input payload or raw vendor issue messages because a +custom validator message may contain application values. + +This policy governs event-definition and validation failures only. This release +does not broaden or redefine the existing initialization and provider-delivery +error contracts, and documentation must not imply that `track()` can never +reject for reasons outside this policy. + +## Validation Errors + +`AnalyticsValidationError` gives applications a stable, validator-independent +shape. It includes: + +- the emitted event name; +- a machine-readable code distinguishing `unknown_event`, + `invalid_properties`, `validator_failure`, and `invalid_output`; and +- normalized Standard Schema issue paths and messages when validation returned + issues. + +The error never stores the submitted properties object. Path normalization must +handle string, number, symbol, and Standard Schema path-segment objects without +throwing. A validator that throws synchronously or rejects asynchronously is a +`validator_failure`, distinct from an ordinary validation result containing +issues. + +`typed()` definitions never enter schema validation. Their inputs can still +fail runtime registry checks such as an unknown event name, but trakoo cannot +validate their property values. + +## Standard Schema Packaging + +Use `@standard-schema/spec` as the canonical source of public Standard Typed and +Standard Schema TypeScript contracts. Imports from that package must be +type-only so no schema implementation is added to trakoo's runtime bundle. +Because emitted trakoo declarations reference these contracts, add +`@standard-schema/spec` to regular `dependencies` with a compatible v1 range. +It is a declaration-resolution dependency even though trakoo emits no runtime +import for it. Verification must install and typecheck the packed tarball in a +clean consumer fixture. + +Users do not need Zod, Valibot, or another validator unless they choose runtime +validation. These packages must not become trakoo dependencies or peer +dependencies. + +Do not ship first-party Zod, Valibot, or ArkType adapters. Standard-compatible +versions work structurally. Adapters for older or non-standard validators may +be considered later as separate compatibility packages if real demand appears. + +## Developer Experience Requirements + +The schema-backed happy path must require: + +- no `as const`; +- no `satisfies EventCollection<...>`; +- no `typeof appEvents` factory generic; +- no exported `AppEvents` type alias; +- no manually repeated schema input or output type; and +- no trakoo-specific validator adapter. + +Type-only events write their property shape exactly once inside `typed()`. +Custom user traits, when used, write their shape exactly once inside the +optional `userTraits: typed()` marker. Neither client nor server factory +requires explicit generic arguments. + +Propertyless events use `noProperties()` and omit the properties argument; they +do not require an empty generic, empty object literal, or `undefined` +placeholder. The server-only options object moves into argument two for these +events. + +Public declarations and TypeScript diagnostics should expose small named helper +types instead of deeply nested conditional or mapped types wherever possible. +Internal type machinery must not leak into routine autocomplete or error +messages. + +The root runtime helpers must be small and environment-neutral so importing +event definitions remains safe in browser, Node, and edge bundles. + +## Breaking Migration + +The release replaces: + +```typescript +export const appEvents = { + buttonClicked: { + name: "button_clicked", + category: "engagement", + properties: {} as { buttonId: string }, + }, +} as const satisfies EventCollection< + Record> +>; + +const analytics = createClientAnalytics({ providers }); +``` + +with: + +```typescript +export const appEvents = defineEvents({ + buttonClicked: { + name: "button_clicked", + category: "engagement", + properties: typed<{ buttonId: string }>(), + }, +}); + +const analytics = createClientAnalytics({ + events: appEvents, + providers, +}); +``` + +Remove the obsolete `CreateEventDefinition`, `EventCollection`, +`ExtractEventNames`, `ExtractEventPropertiesFromCollection`, +`EventMapFromCollection`, `EventDefinition`, `ExtractEventName`, and +`ExtractEventProperties` helpers rather than maintaining two competing +definition systems. Also remove the generic-only factory signatures, the client +singleton, and its untyped module-level convenience functions. Shared low-level +event/provider transport types that remain useful are retained. + +All README examples, core-concept pages, provider guides, and framework guides +must migrate together. A focused migration page will show type-only and +schema-backed conversions side by side and explain the default validation +failure policy. + +## Verification Strategy + +### Type-level coverage + +- Literal names and categories survive `defineEvents()` without `as const`. +- Schema input types drive `track()` arguments. +- Schema output types drive the internal provider event properties. +- Schema transformations require no manual input/output annotations. +- Type-only definitions infer `T` for both input and output. +- Propertyless definitions infer a one-argument client `track()` call and a + server call with optional options in argument two, while rejecting a supplied + properties argument or `undefined` placeholder. +- Unknown names and incorrect properties fail typechecking on both client and + server. +- Factory calls require no event generic. +- Optional user-trait markers type client identification and server user + context without factory generics. +- Representative invalid calls produce short, actionable diagnostics. +- Built declaration files do not require a concrete validator package. +- A clean consumer fixture resolves the published Standard Schema types through + trakoo's regular dependency. + +### Runtime coverage + +- Type-only events bypass validation and reach providers unchanged. +- Synchronous and asynchronous validators succeed. +- Transformed or stripped outputs, rather than original inputs, reach every + provider. +- Returned validation issues, thrown validators, and rejected validators map to + the correct error codes. +- Drop is the default on client and server; throw is opt-in. +- `onError` runs exactly once and cannot alter the configured policy. +- Default debug logging is sanitized. +- Duplicate emitted names fail clearly. +- Unknown runtime names follow the configured failure policy. +- Primitive, null, and array outputs fail as `invalid_output`. +- Propertyless events normalize to an empty provider properties object and + reject supplied runtime properties. +- Disabled analytics bypasses lookup, validation, callbacks, and delivery. +- Provider routing and per-provider failure isolation continue to work after + validation. + +At least one integration test will use a real Standard Schema-compatible +library. Contract-level tests will use small local Standard Typed and Standard +Schema fixtures so the core suite does not depend on vendor-specific APIs. + +### Package and documentation coverage + +- Build all public entry points and inspect their declarations. +- Confirm root event helpers remain environment-neutral. +- Confirm installing trakoo does not install or require a concrete validation + library. +- Update all source and documentation examples in the repository. +- Add a migration guide and link it from the changelog/release notes. + +## Explicit Non-Goals + +- Client-only, server-only, or shared event annotations. +- Runtime enforcement of event environments. +- JSON Schema generation or schema introspection beyond Standard validation. +- Generated analytics documentation. +- A trakoo-owned validation DSL or schema AST. +- First-party adapters for already Standard-compatible libraries. +- Compatibility shims for the old event collection API. +- Changes to provider-specific serialization beyond consuming validated output. +- Redefining initialization or provider-delivery failure behavior. diff --git a/package.json b/package.json index 2cd62d8..ab376dd 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,12 @@ "default": "./dist/index.js" }, "./client": { - "types": "./dist/client.d.ts", + "types": "./dist/client/index.d.ts", "import": "./dist/client.js", "default": "./dist/client.js" }, "./server": { - "types": "./dist/server.d.ts", + "types": "./dist/server/index.d.ts", "import": "./dist/server.js", "default": "./dist/server.js" }, @@ -45,6 +45,7 @@ "test:e2e:visitors": "node e2e/visitors.test.js", "test:e2e:visitors:server": "node e2e/visitors-test-app/server.js", "build": "vite build", + "verify:package": "node scripts/verify-package.mjs", "dev": "pnpm --filter @stacksee/docs dev", "build:docs": "pnpm --filter @stacksee/docs build", "deploy": "npm publish", @@ -87,13 +88,15 @@ "typescript": "~5.5.3", "vite": "^6.3.5", "vite-plugin-dts": "^4.5.4", - "vitest": "^2.0.3" + "vitest": "^2.0.3", + "zod": "^4.4.3" }, "optionalDependencies": { "@bentonow/bento-node-sdk": "^0.2.1", "@emitkit/js": "^2.1.0", "@openpanel/sdk": "^1.3.1", "@openpanel/web": "^1.4.1", + "@types/css-font-loading-module": "0.0.13", "posthog-js": "^1.268.2", "posthog-node": "^5.9.0" }, @@ -101,5 +104,8 @@ "pnpm": ">=9.0.0", "node": ">=20" }, - "packageManager": "pnpm@9.14.4" + "packageManager": "pnpm@9.14.4", + "dependencies": { + "@standard-schema/spec": "^1.1.0" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe94d22..a7183e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 optionalDependencies: '@bentonow/bento-node-sdk': specifier: ^0.2.1 @@ -20,6 +24,9 @@ importers: '@openpanel/web': specifier: ^1.4.1 version: 1.4.1 + '@types/css-font-loading-module': + specifier: 0.0.13 + version: 0.0.13 posthog-js: specifier: ^1.268.2 version: 1.268.2 @@ -66,6 +73,9 @@ importers: vitest: specifier: ^2.0.3 version: 2.1.9(@types/node@20.17.51)(jsdom@26.1.0)(lightningcss@1.33.0) + zod: + specifier: ^4.4.3 + version: 4.4.3 www: dependencies: @@ -1758,6 +1768,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@svitejs/changesets-changelog-github-compact@1.2.0': resolution: {integrity: sha512-08eKiDAjj4zLug1taXSIJ0kGL5cawjVCyJkBb6EWSg5fEPX6L+Wtr0CH2If4j5KYylz85iaZiFlUItvgJvll5g==} engines: {node: ^14.13.1 || ^16.0.0 || >=18} @@ -1957,6 +1970,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/css-font-loading-module@0.0.13': + resolution: {integrity: sha512-EnmFmshMT9rD3yohKkrbMMrRULUVkZbUAJFrFFO12mxiFWQvRKDQYsK1CB8tSUYZQ+uUui5RBvENNHqhL9xqgQ==} + '@types/css-font-loading-module@0.0.7': resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==} @@ -7369,6 +7385,8 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@svitejs/changesets-changelog-github-compact@1.2.0': dependencies: '@changesets/get-github-info': 0.6.0 @@ -7537,6 +7555,9 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@types/css-font-loading-module@0.0.13': + optional: true + '@types/css-font-loading-module@0.0.7': optional: true diff --git a/readme.md b/readme.md index 7b5adff..3dcdb89 100644 --- a/readme.md +++ b/readme.md @@ -4,1067 +4,316 @@ # trakoo -A highly typed, zero-dependency, provider-agnostic analytics library for TypeScript applications. Works seamlessly on both client and server sides with full type safety for your custom events. +A typed, provider-agnostic analytics library for TypeScript applications. Define one event registry, create an application-owned client or server instance, and send the same events to any configured provider. -> **📚 [Full Documentation](https://trakoo.co/docs)** - For complete guides, examples, and provider setup, visit our documentation site. - -## Quick Links - -- 📖 [Documentation](https://trakoo.co/docs) -- 🚀 [Quick Start](https://trakoo.co/docs/quick-start) -- 🔌 [Providers](https://trakoo.co/docs/providers) -- 💡 [Core Concepts](https://trakoo.co/docs/core-concepts) +> **[Read the full documentation](https://trakoo.co/docs)** for framework guides and provider-specific setup. ## Features -- 🎯 **Type-safe events**: Define your own strongly typed events with full IntelliSense support -- 🔌 **Plugin architecture**: Easily add analytics providers by passing them as plugins -- 🌐 **Universal**: Same API works on both client (browser) and server (Node.js) -- 👤 **User context**: Automatically attach user data (email, traits) to all events -- 🏗️ **Framework agnostic**: Use with any JavaScript framework. Can also be used only on the client. -- 🌎 **Edge ready**: The server client is compatible with edge runtime (e.g. Cloudflare Workers, Vercel Edge functions) -- 🔧 **Extensible**: Simple interface to add new providers - -## Providers - -The library includes built-in support for popular analytics services, with more coming soon: - -### Official Providers - -| Provider | Type | Documentation | -|----------|------|---------------| -| **PostHog** | Product Analytics | [View Docs](https://trakoo.co/docs/providers/posthog) | -| **OpenPanel** | Web & Product Analytics | [View Docs](https://trakoo.co/docs/providers/openpanel) | -| **Bento** | Email Marketing & Events | [View Docs](https://trakoo.co/docs/providers/bento) | -| **Pirsch** | Privacy-Focused Web Analytics | [View Docs](https://trakoo.co/docs/providers/pirsch) | - -### Community & Custom Providers - -Want to use a different analytics service? Check out our guide: - -**[Creating Custom Providers →](https://trakoo.co/docs/providers/custom)** - -You can easily create providers for: -- Google Analytics -- Mixpanel -- Amplitude -- Segment -- Customer.io -- Loops -- Any analytics service with a JavaScript SDK - -**[View all provider documentation →](https://trakoo.co/docs/providers)** +- Event names and properties inferred from one registry +- Validator-free TypeScript definitions with optional Standard Schema validation +- Separate browser and server entry points +- Typed user traits and per-event user context +- Provider fan-out and routing +- Fresh, independently configured analytics instances ## Installation ```bash pnpm install trakoo +``` -# For PostHog support -pnpm install posthog-js posthog-node - -# For OpenPanel support -pnpm install @openpanel/web @openpanel/sdk - -# For Bento support (server-side only) -pnpm install @bentonow/bento-node-sdk +Install only the SDKs required by your providers. For example: -# For Pirsch support -pnpm install pirsch-sdk +```bash +pnpm install posthog-js posthog-node ``` -> **See also:** [Provider Documentation](https://trakoo.co/docs/providers) for detailed setup guides for each provider. - -## Quick Start +## Quick start -### 1. Define Your Events +### 1. Define events -Create strongly typed events specific to your application: +The root `trakoo` entry point contains environment-neutral event helpers and shared types. -```typescript -import { CreateEventDefinition, EventCollection } from 'trakoo'; +```typescript title="lib/events.ts" +import { defineEvents, noProperties, typed } from 'trakoo'; -export const appEvents = { +export const appEvents = defineEvents({ userSignedUp: { name: 'user_signed_up', category: 'user', - properties: {} as { + properties: typed<{ userId: string; email: string; plan: 'free' | 'pro' | 'enterprise'; referralSource?: string; - } + }>() }, - featureUsed: { name: 'feature_used', category: 'engagement', - properties: {} as { + properties: typed<{ featureName: string; - userId: string; duration?: number; - } + }>() + }, + sessionStarted: { + name: 'session_started', + category: 'user', + properties: noProperties() } -} as const satisfies EventCollection>>; - -// Optionally extract types for use in your app -export type AppEvents = typeof appEvents; -export type AppEventName = keyof typeof appEvents; -export type AppEventProperties = typeof appEvents[T]['properties']; +}); ``` -Tip: If you have a lot of events, you can also divide your events into multiple files, then export them as a single object. +`typed()` gives you compile-time checking without a runtime validator. It verifies at runtime only that a property-bearing event receives a non-array object. Use `noProperties()` when callers must omit the properties argument entirely. -### 2. Client-Side Usage +### 2. Create a client instance -```typescript +Factories and providers come from environment-specific subpaths. Pass the registry as a value; no event generic is needed. + +```typescript title="lib/analytics.ts" import { createClientAnalytics } from 'trakoo/client'; import { PostHogClientProvider } from 'trakoo/providers/client'; -import type { AppEvents } from './events'; +import { appEvents } from './events'; -// Initialize analytics with providers as plugins -// Pass your event collection as a type parameter for full type safety -const analytics = createClientAnalytics({ +export const analytics = createClientAnalytics({ + events: appEvents, providers: [ new PostHogClientProvider({ - apiKey: 'your-posthog-api-key', - host: 'https://app.posthog.com' // optional - }), - // Add more providers here as needed + token: import.meta.env.VITE_POSTHOG_KEY, + api_host: import.meta.env.VITE_POSTHOG_HOST + }) ], - debug: true, - enabled: true -}); - -// Track events with full type safety - event names and properties are typed! -analytics.track('user_signed_up', { - userId: 'user-123', - email: 'user@example.com', - plan: 'pro', - referralSource: 'google' + debug: import.meta.env.DEV }); +``` -// TypeScript will error if you use wrong event names or properties -// analytics.track('wrong_event', {}); // ❌ Error: Argument of type '"wrong_event"' is not assignable -// analytics.track('user_signed_up', { wrongProp: 'value' }); // ❌ Error: Object literal may only specify known properties +Each factory call returns a fresh instance. trakoo does not keep a global analytics singleton. Create and own the instance in your application, then import that owned instance where you track. -// Identify users - user context is automatically included in all subsequent events -analytics.identify('user-123', { - email: 'user@example.com', - name: 'John Doe', +```typescript +await analytics.track('user_signed_up', { + userId: 'user-123', + email: 'ada@example.com', plan: 'pro' }); -// Now all tracked events automatically include user context -analytics.track('feature_used', { - featureName: 'export-data', - userId: 'user-123' -}); -// Providers receive: context.user = { userId: 'user-123', email: 'user@example.com', traits: {...} } +await analytics.track('session_started'); ``` -### 3. Server-Side Usage +The registry drives autocomplete and rejects misspelled names, missing properties, extra properties, and a properties argument for `session_started`. -```typescript +### 3. Create a server instance + +```typescript title="lib/server-analytics.ts" import { createServerAnalytics } from 'trakoo/server'; import { PostHogServerProvider } from 'trakoo/providers/server'; -import type { AppEvents } from './events'; +import { appEvents } from './events'; -// Create analytics instance with providers as plugins -// Pass your event collection as a type parameter for full type safety -const analytics = createServerAnalytics({ +export const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new PostHogServerProvider({ - apiKey: process.env.POSTHOG_API_KEY, + apiKey: process.env.POSTHOG_API_KEY!, host: process.env.POSTHOG_HOST - }), - // Add more providers here as needed - ], - debug: process.env.NODE_ENV === 'development', - enabled: true + }) + ] }); +``` -// Track events with user context - now returns a Promise with full type safety -await analytics.track('feature_used', { +Server tracking accepts user and request context per call: + +```typescript +await serverAnalytics.track('feature_used', { featureName: 'export-data', - userId: 'user-123', duration: 1500 }, { userId: 'user-123', user: { - email: 'user@example.com', - traits: { - plan: 'pro', - company: 'Acme Corp' - } + email: 'ada@example.com', + traits: { plan: 'pro' } }, context: { - page: { - path: '/api/export', - } + page: { path: '/api/export' } } }); -// Providers receive: context.user = { userId: 'user-123', email: 'user@example.com', traits: {...} } -// Important: Always call shutdown when done, some providers such as Posthog require flushing events. -await analytics.shutdown(); +await serverAnalytics.shutdown(); ``` -## User Context +Call `shutdown()` before a serverless request or worker exits so providers can flush queued events. -The library automatically manages user context, making it easy to include user data (email, traits) in all your analytics events. This is especially useful for providers like Loops or Intercom that require user identifiers. +## Runtime validation with Standard Schema -### How It Works +Standard Schema is an interface implemented by validator libraries; it is not a validator runtime that trakoo requires. The primary API remains validator-free `typed()`. When an event crosses an untrusted boundary, pass a compatible validator directly. Zod implements Standard Schema: -**Client-Side (Stateful):** ```typescript -// 1. Identify the user once (typically after login) -analytics.identify('user-123', { - email: 'user@example.com', - name: 'John Doe', - plan: 'pro', - company: 'Acme Corp' -}); - -// 2. Track events - user context is automatically included -analytics.track('button_clicked', { buttonId: 'checkout' }); - -// Behind the scenes, providers receive: -// { -// event: { action: 'button_clicked', ... }, -// context: { -// user: { -// userId: 'user-123', -// email: 'user@example.com', -// traits: { email: '...', name: '...', plan: '...', company: '...' } -// } -// } -// } - -// 3. Reset on logout to clear user context -analytics.reset(); -``` - -**Server-Side (Stateless):** -```typescript -// Pass user context with each track call -await analytics.track('api_request', { - endpoint: '/users', - method: 'POST' -}, { - userId: 'user-123', - user: { - email: 'user@example.com', - traits: { - plan: 'pro', - company: 'Acme Corp' - } - } -}); - -// Alternatively, pass via context.user -await analytics.track('api_request', { ... }, { - userId: 'user-123', - context: { - user: { - email: 'user@example.com' - } +import { defineEvents } from 'trakoo'; +import { z } from 'zod'; + +export const commerceEvents = defineEvents({ + orderCompleted: { + name: 'order_completed', + category: 'conversion', + properties: z.object({ + orderId: z.string(), + amount: z.coerce.number().positive() + }) } }); ``` -### Using User Context in Custom Providers +The schema's input type controls what `track()` accepts. Its output type controls the validated and transformed properties passed to providers. In this example, `amount` may be a coercible input, but providers always receive a positive number. -When building custom providers, you can access user context from the `EventContext`: +Validation failures are dropped by default. For strict handling, opt into throwing and report the normalized, payload-free error: ```typescript -export class LoopsProvider extends BaseAnalyticsProvider { - name = 'Loops'; - - async track(event: BaseEvent, context?: EventContext): Promise { - // Access user data from context - const email = context?.user?.email; - const userId = context?.user?.userId; - const traits = context?.user?.traits; - - // Loops requires either email or userId - if (!email && !userId) { - this.log('Skipping event - Loops requires email or userId'); - return; - } - - await this.loops.sendEvent({ - ...(email && { email }), - ...(userId && { userId }), - eventName: event.action, - eventProperties: event.properties, - // Optionally include all user traits - contactProperties: traits, - }); +const analytics = createClientAnalytics({ + events: commerceEvents, + providers: [/* ... */], + validation: { + onFailure: 'throw', + onError: (error) => reportValidationFailure(error) } -} +}); ``` -### Security & Privacy +`AnalyticsValidationError` contains a code, event name, and normalized issue messages/paths. Validator issue messages are retained for `onError` and thrown errors, but the complete input payload is never attached to the error. With `debug: true` and no `onError`, the fallback warning deliberately omits issue messages and input values; it contains only the code, event name, and issue paths. -User context is handled securely: +The error callback is awaited before the configured drop or throw policy is applied. Async schemas also mean concurrent `track()` calls can reach providers in validation-completion order rather than call order. Await calls sequentially if delivery order matters. -- ✅ **Memory-only storage** - No localStorage, cookies, or persistence -- ✅ **Session-scoped** - Cleared on `reset()` (logout) -- ✅ **Provider-controlled** - Only sent to providers you configure -- ✅ **No cross-session leaks** - Fresh state on each page load +This validation policy applies only to event lookup and property validation. Initialization failures and provider failures keep their existing behavior. -### Type-Safe User Traits +## Typed user traits -You can define a custom interface for your user traits to get full type safety: +Use another `typed()` marker for custom traits. The factory still infers the event registry from `events`. ```typescript -// Define your user traits interface +import { typed } from 'trakoo'; +import { createClientAnalytics } from 'trakoo/client'; +import { appEvents } from './events'; + interface UserTraits { email: string; name: string; plan: 'free' | 'pro' | 'enterprise'; company?: string; - role?: 'admin' | 'user' | 'viewer'; } -// Client-side with typed traits -const analytics = createClientAnalytics({ +export const analytics = createClientAnalytics({ + events: appEvents, + userTraits: typed(), providers: [/* ... */] }); -// Now identify() and traits are fully typed! analytics.identify('user-123', { - email: 'user@example.com', - name: 'John Doe', - plan: 'pro', // ✅ Autocomplete works! - company: 'Acme Corp', - role: 'admin' -}); - -// TypeScript will error on invalid trait values -analytics.identify('user-123', { - plan: 'invalid' // ❌ Error: Type '"invalid"' is not assignable to type 'free' | 'pro' | 'enterprise' -}); - -// Server-side with typed traits -const serverAnalytics = createServerAnalytics({ - providers: [/* ... */] -}); - -await serverAnalytics.track('event', {}, { - user: { - email: 'user@example.com', - plan: 'pro', // ✅ Fully typed! - traits: { - company: 'Acme Corp' - } - } -}); -``` - -**Benefits:** -- ✅ Full IntelliSense/autocomplete for user traits -- ✅ Compile-time type checking prevents typos -- ✅ Self-documenting code -- ✅ Refactoring safety - -### Client vs Server Differences - -| Feature | Client (Browser) | Server (Node.js) | -|---------|------------------|------------------| -| **State Management** | Stateful - persists after `identify()` | Stateless - pass per request | -| **Usage Pattern** | Call `identify()` once, track many times | Pass `user` option with each `track()` | -| **Reset** | Call `reset()` on logout | No reset needed (stateless) | -| **Use Case** | Single user per session | Multiple users per instance | -| **Type Safety** | `createClientAnalytics` | `createServerAnalytics` | - -### Async Tracking: When to await vs fire-and-forget - -The `track()` method now returns a `Promise`, giving you control over how to handle event tracking: - -#### Fire-and-forget (Client-side typical usage) -```typescript -// Don't await - let events send in the background -analytics.track('button_clicked', { - buttonId: 'checkout', - label: 'Proceed to Checkout' -}); - -// User interaction continues immediately -``` - -#### Await for critical events (Server-side typical usage) -```typescript -// In serverless/edge functions, you have two patterns: - -// Pattern 1: Critical events that MUST complete before response -export async function handler(req, res) { - try { - // Process payment - const paymentResult = await processPayment(req.body); - - // For critical events like payments, await to ensure they're tracked - // This blocks the response but guarantees the event is recorded - await analytics.track('payment_processed', { - amount: paymentResult.amount, - currency: 'USD', - userId: req.userId, - transactionId: paymentResult.id - }); - - return res.json({ success: true, transactionId: paymentResult.id }); - } catch (error) { - // Even on error, you might want to track - await analytics.track('payment_failed', { - error: error.message, - userId: req.userId - }); - - return res.status(500).json({ error: 'Payment failed' }); - } -} - -// Pattern 2: Non-critical events using waitUntil (Vercel example) -import { waitUntil } from '@vercel/functions'; - -export default async function handler(req, res) { - const startTime = Date.now(); - - // Process request - const result = await processRequest(req); - - // Track analytics in background without blocking response - waitUntil( - analytics.track('api_request', { - endpoint: req.url, - duration: Date.now() - startTime, - userId: req.headers['x-user-id'] - }).then(() => analytics.shutdown()) - ); - - // Response sent immediately - return res.json(result); -} -``` - -#### Error handling -```typescript -// The track method catches provider errors internally and logs them -// It won't throw even if a provider fails, ensuring one provider's failure -// doesn't affect others - -// If you need to know about failures, check your logs -await analytics.track('important_event', { data: 'value' }); -// Even if one provider fails, others will still receive the event -``` - -#### Best practices: -- **Client-side**: Usually fire-and-forget for better UX -- **Server-side (serverless)**: Use `waitUntil` for non-critical events to avoid blocking responses -- **Server-side (long-running)**: Can await or fire-and-forget based on criticality -- **Critical events**: Always await (e.g., payments, sign-ups, conversions that must be recorded) -- **High-volume/non-critical events**: Use `waitUntil` in serverless or fire-and-forget in long-running servers -- **Error tracking**: Consider awaiting to ensure errors are captured before function terminates - -### A complete example - -Here's a complete example using Svelte 5 that demonstrates both client and server-side analytics for a waitlist signup: - -```typescript -// src/lib/config/analytics.ts -import { createClientAnalytics } from 'trakoo/client'; -import { PostHogClientProvider } from 'trakoo/providers/client'; -import { PUBLIC_POSTHOG_API_KEY, PUBLIC_POSTHOG_HOST } from '$env/static/public'; - -// Define your events for the waitlist -export const appEvents = { - waitlistJoined: { - name: 'waitlist_joined', - category: 'user', - properties: {} as { - email: string; - source: string; // e.g., 'homepage_banner', 'product_page_modal' - } - }, - waitlistApproved: { - name: 'waitlist_approved', - category: 'user', - properties: {} as { - userId: string; // This could be the email or a generated ID - email: string; - } - } -} as const; - -// Client-side analytics instance -export const clientAnalytics = createClientAnalytics({ - providers: [ - new PostHogClientProvider({ - apiKey: PUBLIC_POSTHOG_API_KEY, - host: PUBLIC_POSTHOG_HOST - }) - ], - debug: import.meta.env.DEV -}); -``` - -```typescript -// src/lib/server/analytics.ts -import { createServerAnalytics } from 'trakoo/server'; -import { PostHogServerProvider } from 'trakoo/providers/server'; -import { AppEvents } from '$lib/config/analytics'; // Import AppEvents -import { PUBLIC_POSTHOG_API_KEY, PUBLIC_POSTHOG_HOST } from '$env/static/public'; - -export const serverAnalytics = createServerAnalytics({ - providers: [ - new PostHogServerProvider({ - apiKey: PUBLIC_POSTHOG_API_KEY, - host: PUBLIC_POSTHOG_HOST - }) - ], - debug: import.meta.env.DEV + email: 'ada@example.com', + name: 'Ada Lovelace', + plan: 'pro' }); ``` -```svelte - - - -

Join Our Waitlist

-
- - -
- -{#if message} -

{message}

-{/if} -``` - -```typescript -// src/routes/api/join-waitlist/+server.ts -import { serverAnalytics } from '$lib/server/analytics'; -import { json, type RequestHandler } from '@sveltejs/kit'; - -async function approveUserForWaitlist(email: string): Promise<{ userId: string }> { - console.log(`Processing waitlist application for: ${email}`); - - const userId = `user_${Date.now()}_${email.split('@')[0]}`; - - return { userId }; -} - -export const POST: RequestHandler = async ({ request }) => { - try { - const body = await request.json(); - const email = body.email; - - if (!email || typeof email !== 'string') { - return json({ success: false, message: 'Email is required' }, { status: 400 }); - } - - const { userId } = await approveUserForWaitlist(email); - - serverAnalytics.track('waitlist_approved', { - userId, - email - }, { - userId, - context: { - page: { - path: '/api/join-waitlist' - }, - ip: request.headers.get('x-forwarded-for') || undefined - } - }); - - // Important: Call shutdown if your application instance is short-lived. (e.g. serverless function) - // For long-running servers, you might call this on server shutdown. - await serverAnalytics.shutdown(); - - return json({ success: true, userId, message: 'Successfully joined and approved for waitlist.' }); - } catch (error) { - console.error('Failed to process waitlist application:', error); - // In production, be careful about leaking error details - const errorMessage = error instanceof Error ? error.message : 'Internal server error'; - return json({ success: false, message: errorMessage }, { status: 500 }); - } - // Note: serverAnalytics.shutdown() should ideally be called when the server itself is shutting down, - // not after every request in a typical web server setup, unless the provider requires it for batching. - // For this example, PostHogServerProvider benefits from shutdown to flush events, - // so if this were, for example, a serverless function processing one event, calling shutdown would be appropriate. - // If it's a long-running server, manage shutdown centrally. -}; -``` - -#### Note for SvelteKit Users: Navigation Tracking +Client analytics remembers the identified user until `reset()`. Server analytics is stateless: pass `userId` and `user` with each `track()` call. -If you're using SvelteKit and want to track page views and page leaves automatically with PostHog (as recommended in their documentation), add this to your root layout: +## Multiple providers and routing ```typescript -// src/app.html or src/routes/+layout.svelte - - -
- {@render children()} -
-``` - -This automatically tracks: -- **Page leaves** before navigation (`$pageleave` events in PostHog) -- **Page views** after navigation (`$pageview` events in PostHog) - -The tracking is framework-agnostic, so you can use similar patterns with Next.js router events, Vue Router hooks, or any other navigation system. - -### Event Categories - -Event categories help organize your analytics data. The SDK provides predefined categories with TypeScript autocomplete: - -- `product` - Product-related events (views, purchases, etc.) -- `user` - User lifecycle events (signup, login, profile updates) -- `navigation` - Page views and navigation events -- `conversion` - Conversion and goal completion events -- `engagement` - Feature usage and interaction events -- `error` - Error tracking events -- `performance` - Performance monitoring events - -You can also use **custom categories** for your specific needs: - -```typescript -export const appEvents = { - aiResponse: { - name: 'ai_response_generated', - category: 'ai', // Custom category - properties: {} as { - model: string; - responseTime: number; - tokensUsed: number; - } - }, - - customWorkflow: { - name: 'workflow_completed', - category: 'workflow', // Another custom category - properties: {} as { - workflowId: string; - duration: number; - steps: number; - } - } -} as const satisfies EventCollection>>; -``` - -### Adding Custom Providers - -Want to integrate with a different analytics service? See our comprehensive guide: - -**[Creating Custom Providers →](https://trakoo.co/docs/providers/custom)** - -Quick example: - -```typescript -import { BaseAnalyticsProvider, BaseEvent, EventContext } from 'trakoo'; - -export class GoogleAnalyticsProvider extends BaseAnalyticsProvider { - name = 'GoogleAnalytics'; - - async initialize(): Promise { /* Initialize GA */ } - track(event: BaseEvent, context?: EventContext): void { /* Track event */ } - identify(userId: string, traits?: Record): void { /* Identify user */ } - // ... implement other required methods -} -``` - -Then use it as a plugin in your configuration: - -```typescript -const analytics = createClientAnalytics({ +const analytics = createClientAnalytics({ + events: appEvents, providers: [ - new PostHogClientProvider({ token: 'xxx' }), - new GoogleAnalyticsProvider({ measurementId: 'xxx' }) + new PostHogClientProvider({ token: 'posthog-token' }), + { + provider: new BentoClientProvider({ siteUuid: 'bento-site' }), + methods: ['identify', 'track'], + events: ['user_signed_up'] + }, + new VisitorsClientProvider({ token: 'visitors-token' }) ] }); ``` -### Client-Only and Server-Only Providers - -**Important**: To avoid bundling Node.js dependencies in your client code, always use the environment-specific provider imports: +Every configured provider receives eligible calls. Routing can restrict methods, exact event names, excluded events, or event-name patterns. -- **Client-side**: `trakoo/providers/client` - Only includes browser-compatible providers -- **Server-side**: `trakoo/providers/server` - Only includes Node.js providers -- **Both**: `trakoo/providers` - Includes all providers (may cause bundling issues in browsers) +## Custom providers -Some analytics libraries are designed to work only in specific environments. For example: -- **Client-only**: Google Analytics (gtag.js), Hotjar, FullStory -- **Server-only**: Some enterprise analytics APIs that require secret keys -- **Universal**: PostHog, Segment (have separate client/server SDKs) - -The library handles this by having separate provider implementations for client and server environments: +Extend the base class from the environment where the provider will run: ```typescript -// Client-side provider for a client-only analytics service -import { BaseAnalyticsProvider, BaseEvent, EventContext } from 'trakoo'; - -export class MixpanelClientProvider extends BaseAnalyticsProvider { - name = 'Mixpanel-Client'; - - constructor(config: { projectToken: string }) { - super(); - // Initialize Mixpanel browser SDK +import { + BaseAnalyticsProvider, + type BaseEvent, + type EventContext +} from 'trakoo/client'; + +export class ConsoleProvider extends BaseAnalyticsProvider { + name = 'Console'; + + initialize() {} + identify(userId: string, traits?: Record) { + console.log('identify', { userId, traits }); } - - // ... implement required methods -} - -// Server-side provider for a server-only analytics service -export class MixpanelServerProvider extends BaseAnalyticsProvider { - name = 'Mixpanel-Server'; - - constructor(config: { projectToken: string; apiSecret: string }) { - super(); - // Initialize Mixpanel server SDK with secret + track(event: BaseEvent, context?: EventContext) { + console.log('track', { event, context }); } - - // ... implement required methods -} -``` - -Then use the appropriate provider based on your environment: - -```typescript -// Client-side usage -import { createClientAnalytics } from 'trakoo/client'; -import { MixpanelClientProvider } from './providers/mixpanel-client'; - -const clientAnalytics = createClientAnalytics({ - providers: [ - new MixpanelClientProvider({ projectToken: 'xxx' }) - ] -}); - -// Server-side usage -import { createServerAnalytics } from 'trakoo/server'; -import { MixpanelServerProvider } from './providers/mixpanel-server'; - -const serverAnalytics = createServerAnalytics({ - providers: [ - new MixpanelServerProvider({ - projectToken: 'xxx', - apiSecret: 'secret-xxx' // Server-only configuration - }) - ] -}); -``` - -**Important notes:** -- Client providers should only use browser-compatible APIs -- Server providers can use Node.js-specific features and secret credentials -- The provider interface is the same, ensuring consistent usage patterns -- Import paths are separate (`/client` vs `/server`) to prevent accidental usage in wrong environments - -### Using Multiple Providers - -The plugin architecture makes it easy to send events to multiple analytics services simultaneously: - -```typescript -import { createClientAnalytics } from 'trakoo/client'; -import { PostHogClientProvider } from 'trakoo/providers/client'; -// Import your custom providers -import { GoogleAnalyticsProvider } from './providers/google-analytics'; -import { MixpanelProvider } from './providers/mixpanel'; - -const analytics = createClientAnalytics({ - providers: [ - // PostHog for product analytics - new PostHogClientProvider({ - apiKey: process.env.NEXT_PUBLIC_POSTHOG_KEY, - host: 'https://app.posthog.com' - }), - - // Google Analytics for marketing insights - new GoogleAnalyticsProvider({ - measurementId: process.env.NEXT_PUBLIC_GA_ID - }), - - // Mixpanel for detailed user journey analysis - new MixpanelProvider({ - projectToken: process.env.NEXT_PUBLIC_MIXPANEL_TOKEN - }) - ], - debug: process.env.NODE_ENV === 'development', - enabled: true -}); - -// All providers will receive this event -analytics.track('user_signed_up', { - userId: 'user-123', - plan: 'pro' -}); -``` - -## Server Deployments and waitUntil - -When deploying your application to serverless environments, it's important to handle analytics events properly to ensure they are sent before the function terminates. Different platforms provide their own mechanisms for this: - -### Vercel Functions - -Vercel provides a `waitUntil` API that allows you to continue processing after the response has been sent: - -```typescript -import { waitUntil } from '@vercel/functions'; - -export default async function handler(req, res) { - const analytics = createServerAnalytics({ - providers: [new PostHogServerProvider({ apiKey: process.env.POSTHOG_API_KEY })] - }); - - // Process your request and prepare response - const result = { success: true, data: 'processed' }; - - // Use waitUntil to track events and flush without blocking the response - waitUntil( - analytics.track('api_request', { - endpoint: '/api/users', - method: 'POST', - statusCode: 200, - responseTime: 150 - }).then(() => analytics.shutdown()) - ); - - // Response is sent immediately, tracking happens in background - res.status(200).json(result); -} -``` - -### Cloudflare Workers - -Cloudflare Workers provides a `waitUntil` method on the execution context: - -```typescript -export default { - async fetch(request, env, ctx) { - const analytics = createServerAnalytics({ - providers: [new PostHogServerProvider({ apiKey: env.POSTHOG_API_KEY })] - }); - - // Process request and prepare response - const response = new Response('OK', { status: 200 }); - - // Use ctx.waitUntil to track events and flush without blocking the response - ctx.waitUntil( - analytics.track('worker_execution', { - url: request.url, - method: request.method, - cacheStatus: 'MISS', - executionTime: 45 - }).then(() => analytics.shutdown()) - ); - - // Response is returned immediately, tracking happens in background - return response; + pageView(properties?: Record) { + console.log('page view', properties); } -}; -``` - -### Netlify Functions - -Netlify Functions also support `waitUntil` through their context object: - -```typescript -export async function handler(event, context) { - const analytics = createServerAnalytics({ - providers: [new PostHogServerProvider({ apiKey: process.env.POSTHOG_API_KEY })] - }); - - const responseBody = { success: true, data: 'processed' }; - - // Use context.waitUntil to track events and flush without blocking the response - context.waitUntil( - analytics.track('function_invocation', { - path: event.path, - httpMethod: event.httpMethod, - queryStringParameters: event.queryStringParameters, - executionTime: 120 - }).then(() => analytics.shutdown()) - ); - - // Response is returned immediately, tracking happens in background - return { - statusCode: 200, - body: JSON.stringify(responseBody) - }; + reset() {} } ``` -**Important Notes:** -1. Always call `analytics.shutdown()` within `waitUntil` to ensure events are sent -2. The `waitUntil` API is platform-specific, so make sure to use the correct import/usage for your deployment platform -3. For long-running servers (not serverless), you should call `shutdown()` when the server itself is shutting down -4. Some providers may batch events, so `shutdown()` ensures all pending events are sent +See [Creating Custom Providers](https://trakoo.co/docs/providers/custom) for the full lifecycle. -## API Reference +## Import map -### Client API +| Import | Contents | +|---|---| +| `trakoo` | `defineEvents`, `typed`, `noProperties`, validation error, and shared types | +| `trakoo/client` | Client factory, browser analytics class, client-safe base provider exports | +| `trakoo/server` | Server factory, server analytics class, server-safe base provider exports | +| `trakoo/providers/client` | Browser provider implementations | +| `trakoo/providers/server` | Server provider implementations | -#### `createClientAnalytics(config)` -Initialize analytics for browser environment with optional type-safe events. +Do not import factories from the root or use a combined provider entry point. -- `TEvents` - (optional) Your event collection type for full type safety -- `config.providers` - Array of analytics provider instances -- `config.debug` - Enable debug logging -- `config.enabled` - Enable/disable analytics +## API summary -```typescript -const analytics = createClientAnalytics({ - providers: [/* ... */], - debug: true, - enabled: true -}); -``` +### `defineEvents(definitions)` -#### `BrowserAnalytics` -- `track(eventName, properties): Promise` - Track an event with type-safe event names and properties. User context from `identify()` is automatically included. -- `identify(userId, traits)` - Identify a user and store their traits. All subsequent `track()` calls will include this user context. -- `pageView(properties)` - Track a page view -- `pageLeave(properties)` - Track a page leave event -- `reset()` - Reset user session, clearing userId and user traits -- `updateContext(context)` - Update event context +Creates the branded runtime registry required by both factories. Duplicate wire names throw while the registry is created. -### Server API +### `typed()` -#### `createServerAnalytics(config)` -Create analytics instance for server environment with optional type-safe events. +Declares an object-shaped compile-time input/output type without validating its fields at runtime. -- `TEvents` - (optional) Your event collection type for full type safety -- `config.providers` - Array of analytics provider instances -- `config.debug` - Enable debug logging -- `config.enabled` - Enable/disable analytics +### `noProperties()` -```typescript -const analytics = createServerAnalytics({ - providers: [/* ... */], - debug: true, - enabled: true -}); -``` - -#### `ServerAnalytics` -- `track(eventName, properties, options): Promise` - Track an event with type-safe event names and properties. Pass user context via `options.user` or `options.context.user`. - - `options.userId` - User ID for this event - - `options.sessionId` - Session ID for this event - - `options.user` - User context (email, traits) for this event - - `options.context` - Additional event context (page, device, etc.) -- `identify(userId, traits)` - Identify a user (sends to providers but doesn't persist on server) -- `pageView(properties, options)` - Track a page view -- `pageLeave(properties, options)` - Track a page leave event -- `shutdown()` - Flush pending events and cleanup +Declares an event that must be tracked without a properties argument. -### Type Helpers +### `createClientAnalytics(config)` -- `CreateEventDefinition` - Define a single event -- `EventCollection` - Define a collection of events -- `ExtractEventNames` - Extract event names from a collection -- `ExtractEventPropertiesFromCollection` - Extract properties for a specific event +Requires `config.events`. Optional configuration includes `providers`, `userTraits`, `validation`, `debug`, and `enabled`. Returns a fresh client instance. -## Best Practices +### `createServerAnalytics(config)` -1. **Define events in a central location** - Keep all event definitions in one file for consistency -2. **Use const assertions** - Use `as const` for better type inference -3. **Initialize early** - Initialize analytics as early as possible in your app lifecycle -4. **Handle errors gracefully** - Analytics should never break your app -5. **Respect privacy** - Implement user consent and opt-out mechanisms -6. **Test your events** - Verify events are tracked correctly in development -7. **Document events** - Add comments to explain when each event should be fired -8. **Create provider instances once** - Reuse provider instances across your app +Requires `config.events`. Optional configuration includes `providers`, `userTraits`, `validation`, `debug`, `enabled`, and `defaultContext`. Returns a fresh server instance. ---- +## Best practices -## Learn More +1. Keep a single authoritative registry and pass that registry value to every analytics factory. +2. Prefer `typed()`; add a Standard Schema validator only at boundaries that need runtime checking or transformation. +3. Model zero-property events with `noProperties()`. +4. Create analytics and provider instances in application-owned modules; do not rely on hidden global state. +5. Use client/server subpaths so environment-specific code stays out of the wrong bundle. +6. Await server events and call `shutdown()` when delivery must complete before the runtime exits. +7. Never send secrets or unnecessary personal data to analytics providers. -This README provides a quick overview. For comprehensive documentation, guides, and examples: +## Learn more -**📚 [Visit the Full Documentation](https://trakoo.co/docs)** - -- [Quick Start Guide](https://trakoo.co/docs/quick-start) +- [Quick Start](https://trakoo.co/docs/quick-start) - [Core Concepts](https://trakoo.co/docs/core-concepts) -- [Provider Setup Guides](https://trakoo.co/docs/providers) +- [Provider Setup](https://trakoo.co/docs/providers) - [Framework Guides](https://trakoo.co/docs/guides) ## Contributing -Contributions are welcome! Please read our contributing guidelines before submitting PRs. - -## License - -MIT +Contributions are welcome. Please open an issue or pull request with a focused description and tests where behavior changes. diff --git a/scripts/package-verification.mjs b/scripts/package-verification.mjs new file mode 100644 index 0000000..44bdec3 --- /dev/null +++ b/scripts/package-verification.mjs @@ -0,0 +1,73 @@ +import { readFileSync } from "node:fs"; +import { + dirname, + extname, + isAbsolute, + relative, + resolve, + sep, +} from "node:path"; + +const staticImportPattern = + /\b(?:import\s*(?:[^"'()]*?\bfrom\s*)?|export\s*[^"'()]*?\bfrom\s*)["']([^"']+)["']/g; + +function isInside(directory, filePath) { + const relativePath = relative(directory, filePath); + return ( + relativePath === "" || + (!relativePath.startsWith(`..${sep}`) && + relativePath !== ".." && + !isAbsolute(relativePath)) + ); +} + +function relativeJavaScriptImports(source, importerPath, distDirectory) { + const imports = []; + for (const match of source.matchAll(staticImportPattern)) { + const specifier = match[1]; + if (!specifier?.startsWith(".")) continue; + + const importedPath = resolve( + dirname(importerPath), + specifier.split(/[?#]/, 1)[0], + ); + if ( + isInside(distDirectory, importedPath) && + [".js", ".mjs"].includes(extname(importedPath)) + ) { + imports.push(importedPath); + } + } + return imports; +} + +export function assertRootBundleNeutral( + entryPath, + distDirectory, + prohibitedPackages, +) { + const pending = [resolve(entryPath)]; + const visited = new Set(); + + while (pending.length > 0) { + const filePath = pending.pop(); + if (!filePath || visited.has(filePath)) continue; + visited.add(filePath); + + const source = readFileSync(filePath, "utf8"); + for (const packageName of prohibitedPackages) { + if (source.includes(packageName)) { + const relativePath = relative(distDirectory, filePath) + .split(sep) + .join("/"); + throw new Error( + `root bundle includes ${packageName} in ${relativePath}`, + ); + } + } + + pending.push( + ...relativeJavaScriptImports(source, filePath, distDirectory), + ); + } +} diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..0fa89f9 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,180 @@ +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { assertRootBundleNeutral } from "./package-verification.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const consumerDirectory = mkdtempSync( + join(tmpdir(), "trakoo-package-consumer-"), +); +let tarballPath; + +const run = (command, args, cwd = root) => + execFileSync(command, args, { cwd, encoding: "utf8", stdio: "pipe" }); + +const consumerSource = String.raw` +import { defineEvents, noProperties, typed } from "trakoo"; +import { + AnalyticsValidationError as ClientValidationError, + createClientAnalytics, + type ClientAnalyticsConfig, + type EventInputMap as ClientEventInputMap, +} from "trakoo/client"; + +const events = defineEvents({ + clicked: { + name: "clicked", + category: "engagement", + properties: typed<{ id: string }>(), + }, + started: { + name: "started", + category: "user", + properties: noProperties(), + }, +}); + +const analytics = createClientAnalytics({ events, providers: [] }); +analytics.track("clicked", { id: "cta" }); +analytics.track("started"); + +type ClickInput = ClientEventInputMap["clicked"]; +void ({} as ClientAnalyticsConfig); +void ({} as ClickInput); +void ClientValidationError; +`; + +try { + run("pnpm", ["build"]); + const packResult = JSON.parse(run("npm", ["pack", "--json"])); + tarballPath = resolve(root, packResult[0].filename); + + run("npm", ["init", "-y"], consumerDirectory); + run( + "npm", + ["install", "--ignore-scripts", tarballPath], + consumerDirectory, + ); + + writeFileSync(join(consumerDirectory, "consumer.ts"), consumerSource); + writeFileSync( + join(consumerDirectory, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + strict: true, + noEmit: true, + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + }, + include: ["consumer.ts"], + }, + null, + 2, + ), + ); + run( + process.execPath, + [ + resolve(root, "node_modules/typescript/bin/tsc"), + "--project", + join(consumerDirectory, "tsconfig.json"), + ], + consumerDirectory, + ); + + const installedManifest = JSON.parse( + readFileSync( + join(consumerDirectory, "node_modules/trakoo/package.json"), + "utf8", + ), + ); + if (!installedManifest.dependencies?.["@standard-schema/spec"]) { + throw new Error("packed trakoo is missing @standard-schema/spec dependency"); + } + const fontTypesManifest = JSON.parse( + readFileSync( + join( + consumerDirectory, + "node_modules/@types/css-font-loading-module/package.json", + ), + "utf8", + ), + ); + if (fontTypesManifest.version !== "0.0.13") { + throw new Error( + `packed consumer hoisted unexpected css font types ${fontTypesManifest.version}`, + ); + } + + const concreteValidators = ["zod", "valibot", "arktype"]; + for (const field of [ + "dependencies", + "optionalDependencies", + "peerDependencies", + ]) { + for (const packageName of concreteValidators) { + if (installedManifest[field]?.[packageName]) { + throw new Error( + `packed trakoo declares concrete validator ${packageName}`, + ); + } + } + } + + const installedDist = join( + consumerDirectory, + "node_modules/trakoo/dist", + ); + assertRootBundleNeutral(join(installedDist, "index.js"), installedDist, [ + ...concreteValidators, + "posthog-js", + "posthog-node", + "@openpanel/sdk", + "@openpanel/web", + "@bentonow/bento-node-sdk", + "@emitkit/js", + ]); + + // Prove root event helpers load without optional provider packages present. + run("npm", ["prune", "--omit=optional"], consumerDirectory); + writeFileSync( + join(consumerDirectory, "consumer.ts"), + String.raw` +import { defineEvents, typed } from "trakoo"; + +defineEvents({ + checked: { + name: "checked", + category: "test", + properties: typed<{ value: string }>(), + }, +}); +`, + ); + run( + process.execPath, + [ + resolve(root, "node_modules/typescript/bin/tsc"), + "--project", + join(consumerDirectory, "tsconfig.json"), + ], + consumerDirectory, + ); + run( + process.execPath, + ["--input-type=module", "--eval", 'await import("trakoo")'], + consumerDirectory, + ); +} finally { + if (tarballPath) rmSync(tarballPath, { force: true }); + rmSync(consumerDirectory, { recursive: true, force: true }); +} diff --git a/src/adapters/client/browser-analytics.ts b/src/adapters/client/browser-analytics.ts index 4248a32..1ad4fb8 100644 --- a/src/adapters/client/browser-analytics.ts +++ b/src/adapters/client/browser-analytics.ts @@ -1,16 +1,34 @@ import type { - AnalyticsConfig, AnalyticsProvider, BaseEvent, - EventCategory, EventContext, ProviderConfigOrProvider, ProviderMethod, } from "@/core/events/types.js"; +import type { + ClientTrackArgs, + EventDefinitions, + EventName, + EventOutputMap, + EventRegistry, +} from "@/core/events/registry.js"; +import { + resolveEvent, + type ValidationConfig, +} from "@/core/events/validation.js"; import { isBrowser } from "@/utils/environment"; -// Default event map type -type DefaultEventMap = Record>; +export interface BrowserAnalyticsConfig< + TRegistry extends EventRegistry, + TUserTraits extends object = Record, +> { + readonly events: TRegistry; + readonly providers: ProviderConfigOrProvider[]; + readonly validation?: ValidationConfig; + readonly debug?: boolean; + readonly enabled?: boolean; + readonly defaultContext?: Partial>; +} /** * Internal normalized provider configuration @@ -24,8 +42,8 @@ interface NormalizedProviderConfig { } export class BrowserAnalytics< - TEventMap extends DefaultEventMap = DefaultEventMap, - TUserTraits extends Record = Record, + TRegistry extends EventRegistry, + TUserTraits extends object = Record, > { private providerConfigs: NormalizedProviderConfig[] = []; private context: EventContext = {}; @@ -34,6 +52,10 @@ export class BrowserAnalytics< private userTraits?: TUserTraits; private initialized = false; private initializePromise?: Promise; + private readonly registry: TRegistry; + private readonly validation?: ValidationConfig; + private readonly debug: boolean; + private readonly enabled: boolean; /** * Creates a new BrowserAnalytics instance for client-side event tracking. @@ -41,36 +63,44 @@ export class BrowserAnalytics< * Automatically generates a session ID and sets up the analytics context. * The instance will be ready to track events once initialized. * - * @param config Analytics configuration including providers and default context + * @param config Analytics configuration including an event registry, providers, and default context + * @param config.events Runtime event registry created with `defineEvents()` * @param config.providers Array of analytics provider instances (e.g., PostHogClientProvider) * @param config.defaultContext Optional default context to include with all events * * @example * ```typescript + * import { defineEvents } from 'trakoo'; * import { BrowserAnalytics } from 'trakoo/client'; * import { PostHogClientProvider } from 'trakoo/providers/client'; * + * const events = defineEvents({}); * const analytics = new BrowserAnalytics({ + * events, * providers: [ * new PostHogClientProvider({ - * apiKey: 'your-posthog-api-key', + * token: 'your-posthog-api-key', * api_host: 'https://app.posthog.com' * }) * ], * defaultContext: { - * app: { version: '1.0.0' } + * page: { path: '/', title: 'Example app' } * } * }); * * await analytics.initialize(); * ``` */ - constructor(config: AnalyticsConfig) { + constructor(config: BrowserAnalyticsConfig) { + this.registry = config.events; + this.validation = config.validation; + this.debug = config.debug === true; + this.enabled = config.enabled !== false; this.providerConfigs = this.normalizeProviders(config.providers); // Set default context if (config.defaultContext) { - this.context = { ...config.defaultContext } as EventContext; + this.context = { ...config.defaultContext }; } // Generate session ID @@ -246,13 +276,23 @@ export class BrowserAnalytics< * * @example * ```typescript - * const analytics = new BrowserAnalytics({ providers: [] }); + * import { defineEvents, typed } from 'trakoo'; + * import { BrowserAnalytics } from 'trakoo/client'; + * + * const events = defineEvents({ + * pageViewed: { + * name: 'page_viewed', + * category: 'navigation', + * properties: typed<{ page: string }>(), + * }, + * }); + * const analytics = new BrowserAnalytics({ events, providers: [] }); * * // Initialize before tracking events * await analytics.initialize(); * * // Now ready to track events - * analytics.track('page_viewed', { page: '/dashboard' }); + * await analytics.track('page_viewed', { page: '/dashboard' }); * ``` * * @example @@ -263,6 +303,7 @@ export class BrowserAnalytics< * ``` */ async initialize(): Promise { + if (!this.enabled) return; if (!isBrowser()) return; if (this.initialized) return; @@ -345,7 +386,7 @@ export class BrowserAnalytics< * }); * * // Now all subsequent track() calls automatically include user context - * analytics.track('button_clicked', { buttonId: 'checkout' }); + * await analytics.track('button_clicked', { buttonId: 'checkout' }); * // Providers receive: context.user = { userId: 'user-123', email: 'john@example.com', traits: {...} } * ``` * @@ -374,6 +415,8 @@ export class BrowserAnalytics< * ``` */ identify(userId: string, traits?: TUserTraits): void { + if (!this.enabled) return; + this.userId = userId; this.userTraits = traits; @@ -384,7 +427,10 @@ export class BrowserAnalytics< for (const config of this.providerConfigs) { if (this.shouldCallMethod(config, "identify")) { - config.provider.identify(userId, traits); + config.provider.identify( + userId, + traits as Record | undefined, + ); } } } @@ -426,7 +472,7 @@ export class BrowserAnalytics< * }); * * // Now all events automatically include user context - * analytics.track('button_clicked', { buttonId: 'checkout' }); + * await analytics.track('button_clicked', { buttonId: 'checkout' }); * // Providers receive: context.user = { userId: 'user-123', email: 'user@example.com', traits: {...} } * ``` * @@ -448,8 +494,9 @@ export class BrowserAnalytics< * @example * ```typescript * // Fire-and-forget for non-critical events (client-side typical usage) - * analytics.track('feature_viewed', { feature: 'dashboard' }); - * // Don't await - let it track in the background + * void analytics.track('feature_viewed', { feature: 'dashboard' }).catch((error) => { + * console.error('Background analytics failed:', error); + * }); * ``` * * @example @@ -458,23 +505,36 @@ export class BrowserAnalytics< * try { * await analytics.track('critical_event', { data: 'important' }); * } catch (error) { - * // Individual provider failures are handled internally - * // This catch would only trigger for initialization failures + * // Strict validation rejects with AnalyticsValidationError. + * // Browser initialization can also reject; provider track failures are isolated and logged. * console.error('Failed to track event:', error); * } * ``` */ - async track( - eventName: TEventName, - properties: TEventMap[TEventName], + async track>( + ...args: ClientTrackArgs ): Promise { + if (!this.enabled) return; + // Ensure initialization but don't block the track call await this.ensureInitialized(); - const event: BaseEvent = { - action: eventName, - category: this.getCategoryFromEventName(eventName), - properties: properties as Record, + const eventName = args[0]; + const input = args.length > 1 ? (args as readonly unknown[])[1] : undefined; + const resolved = await resolveEvent( + this.registry, + eventName, + input, + args.length > 1, + this.validation, + this.debug, + ); + if (!resolved) return; + + const event: BaseEvent[TName]> = { + action: resolved.name, + category: resolved.category, + properties: resolved.properties, timestamp: Date.now(), userId: this.userId, sessionId: this.sessionId, @@ -505,7 +565,10 @@ export class BrowserAnalytics< ) .map(async (config) => { try { - await config.provider.track(event, contextWithUser); + await config.provider.track( + event as BaseEvent, + contextWithUser as EventContext, + ); } catch (error) { // Log error but don't throw - one provider failing shouldn't break others console.error( @@ -580,6 +643,8 @@ export class BrowserAnalytics< * ``` */ pageView(properties?: Record): void { + if (!this.enabled) return; + // Run initialization if needed, but don't block this.ensureInitialized().catch((error) => { console.error("[Analytics] Failed to initialize during pageView:", error); @@ -596,7 +661,7 @@ export class BrowserAnalytics< for (const config of this.providerConfigs) { if (this.shouldCallMethod(config, "pageView")) { - config.provider.pageView(properties, this.context); + config.provider.pageView(properties, this.context as EventContext); } } } @@ -657,6 +722,8 @@ export class BrowserAnalytics< * ``` */ pageLeave(properties?: Record): void { + if (!this.enabled) return; + // Run initialization if needed, but don't block this.ensureInitialized().catch((error) => { console.error( @@ -670,7 +737,7 @@ export class BrowserAnalytics< this.shouldCallMethod(config, "pageLeave") && config.provider.pageLeave ) { - config.provider.pageLeave(properties, this.context); + config.provider.pageLeave(properties, this.context as EventContext); } } } @@ -720,7 +787,7 @@ export class BrowserAnalytics< * analytics.identify(newUserId); * * // Track account switch - * analytics.track('account_switched', { + * await analytics.track('account_switched', { * newUserId, * timestamp: Date.now() * }); @@ -728,6 +795,8 @@ export class BrowserAnalytics< * ``` */ reset(): void { + if (!this.enabled) return; + this.userId = undefined; this.userTraits = undefined; this.sessionId = this.generateSessionId(); @@ -754,7 +823,7 @@ export class BrowserAnalytics< * @example * ```typescript * // Flush before navigation - * analytics.track('button_clicked', { buttonId: 'checkout' }); + * await analytics.track('button_clicked', { buttonId: 'checkout' }); * await analytics.flush(); * window.location.href = '/checkout'; * ``` @@ -774,9 +843,14 @@ export class BrowserAnalytics< * ``` */ async flush(useBeacon = false): Promise { + if (!this.enabled) return; + const flushPromises = this.providerConfigs.map(async (config) => { // Only call flush if the provider has the method - if (config.provider.flush && typeof config.provider.flush === "function") { + if ( + config.provider.flush && + typeof config.provider.flush === "function" + ) { try { await config.provider.flush(useBeacon); } catch (error) { @@ -876,17 +950,6 @@ export class BrowserAnalytics< }; } - private getCategoryFromEventName(eventName: string): EventCategory { - // Extract category from event name pattern: category_action - const parts = eventName.split("_"); - // Only use the first part as category if there's actually an underscore - if (parts.length > 1 && parts[0]) { - return parts[0]; - } - - return "engagement"; // Default fallback category - } - private generateSessionId(): string { return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; } diff --git a/src/adapters/server/server-analytics.ts b/src/adapters/server/server-analytics.ts index 6f8a76a..4c45052 100644 --- a/src/adapters/server/server-analytics.ts +++ b/src/adapters/server/server-analytics.ts @@ -1,17 +1,76 @@ -import type { AnyEventName, AnyEventProperties } from "@/core/events/index.js"; +import { + getEventClassification, + type EventDefinitions, + type EventName, + type EventOutputMap, + type EventRegistry, + type ServerTrackArgs, +} from "@/core/events/registry.js"; import type { - AnalyticsConfig, AnalyticsProvider, BaseEvent, - EventCategory, EventContext, ProviderConfigOrProvider, ProviderMethod, UserContext, } from "@/core/events/types.js"; +import { + AnalyticsValidationError, + applyValidationFailurePolicy, + resolveEvent, + resolveReplayEvent, + type ResolvedEvent, + type ValidationConfig, +} from "@/core/events/validation.js"; + +export const serverAnalyticsReplay: unique symbol = Symbol( + "trakoo.serverAnalytics.replay", +); + +export interface ServerAnalyticsReplayAccess { + readonly [serverAnalyticsReplay]: ( + eventName: string, + properties: unknown, + options: ServerTrackOptions | undefined, + ) => Promise; +} -// Default event map type - allows any event with any properties when no specific map is provided -type DefaultEventMap = Record>; +export interface ServerTrackOptions { + readonly userId?: string; + readonly sessionId?: string; + readonly context?: EventContext; + readonly user?: UserContext; +} + +export interface ServerAnalyticsAdapterConfig< + TRegistry extends EventRegistry, + TUserTraits extends object = Record, +> { + readonly events: TRegistry; + readonly providers: ProviderConfigOrProvider[]; + readonly validation?: ValidationConfig; + readonly debug?: boolean; + readonly enabled?: boolean; + readonly defaultContext?: Partial>; +} + +const serverTrackOptionKeys = new Set([ + "userId", + "sessionId", + "context", + "user", +]); + +function isServerTrackOptions( + value: unknown, +): value is ServerTrackOptions { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.keys(value).every((key) => serverTrackOptionKeys.has(key)) + ); +} /** * Internal normalized provider configuration @@ -25,12 +84,17 @@ interface NormalizedProviderConfig { } export class ServerAnalytics< - TEventMap extends Record> = DefaultEventMap, - TUserTraits extends Record = Record, -> { + TRegistry extends EventRegistry, + TUserTraits extends object = Record, +> implements ServerAnalyticsReplayAccess +{ private providerConfigs: NormalizedProviderConfig[] = []; - private config: AnalyticsConfig; private initialized = false; + private readonly registry: TRegistry; + private readonly validation?: ValidationConfig; + private readonly debug: boolean; + private readonly enabled: boolean; + private readonly defaultContext?: Partial>; /** * Creates a new ServerAnalytics instance for server-side event tracking. @@ -38,32 +102,40 @@ export class ServerAnalytics< * The server analytics instance is designed for Node.js environments including * long-running servers, serverless functions, and edge computing environments. * - * @param config Analytics configuration including providers and default context + * @param config Analytics configuration including an event registry, providers, and default context + * @param config.events Runtime event registry created with `defineEvents()` * @param config.providers Array of analytics provider instances (e.g., PostHogServerProvider) * @param config.defaultContext Optional default context to include with all events * * @example * ```typescript + * import { defineEvents } from 'trakoo'; * import { ServerAnalytics } from 'trakoo/server'; * import { PostHogServerProvider } from 'trakoo/providers/server'; * + * const events = defineEvents({}); * const analytics = new ServerAnalytics({ + * events, * providers: [ * new PostHogServerProvider({ - * apiKey: process.env.POSTHOG_API_KEY, - * host: process.env.POSTHOG_HOST + * apiKey: 'your-posthog-api-key', + * host: 'https://app.posthog.com' * }) * ], * defaultContext: { - * app: { version: '1.0.0', environment: 'production' } + * server: { version: '1.0.0', environment: 'production' } * } * }); * * analytics.initialize(); * ``` */ - constructor(config: AnalyticsConfig) { - this.config = config; + constructor(config: ServerAnalyticsAdapterConfig) { + this.registry = config.events; + this.validation = config.validation; + this.debug = config.debug === true; + this.enabled = config.enabled !== false; + this.defaultContext = config.defaultContext; this.providerConfigs = this.normalizeProviders(config.providers); } @@ -233,7 +305,17 @@ export class ServerAnalytics< * * @example * ```typescript - * const analytics = new ServerAnalytics({ providers: [] }); + * import { defineEvents, typed } from 'trakoo'; + * import { ServerAnalytics } from 'trakoo/server'; + * + * const events = defineEvents({ + * apiRequest: { + * name: 'api_request', + * category: 'system', + * properties: typed<{ endpoint: string }>(), + * }, + * }); + * const analytics = new ServerAnalytics({ events, providers: [] }); * * // Initialize before tracking events * analytics.initialize(); @@ -244,9 +326,20 @@ export class ServerAnalytics< * * @example * ```typescript + * import { defineEvents, typed } from 'trakoo'; + * import { ServerAnalytics } from 'trakoo/server'; + * + * const events = defineEvents({ + * functionInvoked: { + * name: 'function_invoked', + * category: 'system', + * properties: typed<{ path: string; method: string }>(), + * }, + * }); + * * // In a serverless function * export async function handler(req, res) { - * const analytics = new ServerAnalytics({ providers: [] }); + * const analytics = new ServerAnalytics({ events, providers: [] }); * analytics.initialize(); // Quick synchronous initialization * * await analytics.track('function_invoked', { @@ -259,6 +352,7 @@ export class ServerAnalytics< * ``` */ initialize(): void { + if (!this.enabled) return; if (this.initialized) return; // Initialize all providers synchronously (initialize is always called regardless of routing) @@ -315,10 +409,17 @@ export class ServerAnalytics< * } * ``` */ - async identify(userId: string, traits?: Record): Promise { + async identify(userId: string, traits?: TUserTraits): Promise { + if (!this.enabled) return; + const promises = this.providerConfigs .filter((config) => this.shouldCallMethod(config, "identify")) - .map((config) => config.provider.identify(userId, traits)); + .map((config) => + config.provider.identify( + userId, + traits as Record | undefined, + ), + ); const results = await Promise.allSettled(promises); @@ -444,42 +545,140 @@ export class ServerAnalytics< * currency: 'USD' * }); * } catch (error) { - * // This only catches initialization errors - * // Individual provider failures are logged but don't throw + * // Strict validation rejects with AnalyticsValidationError. + * // Initialization errors happen during initialize(); provider track failures are isolated and logged. * console.error('Failed to track event:', error); * } * ``` */ - async track( - eventName: TEventName, - properties: TEventName extends keyof TEventMap - ? TEventMap[TEventName] - : Record, - options?: { - userId?: string; - sessionId?: string; - context?: EventContext; - user?: UserContext; - }, + async track>( + ...args: ServerTrackArgs> + ): Promise { + if (!this.enabled) return; + + if (!this.initialized) { + console.warn("[Analytics] Not initialized. Call initialize() first."); + return; + } + + const argumentValues: readonly unknown[] = args; + const eventName = args[0]; + const classification = getEventClassification(this.registry, eventName); + const secondArgument = argumentValues[1]; + let input = secondArgument; + let inputProvided = args.length > 1; + let options: ServerTrackOptions | undefined; + + if (classification?.kind === "none") { + if ( + args.length === 2 && + isServerTrackOptions(secondArgument) + ) { + options = secondArgument; + input = undefined; + inputProvided = false; + } else if (args.length === 1) { + input = undefined; + inputProvided = false; + } else if (args.length === 3 && secondArgument === undefined) { + const thirdArgument = argumentValues[2]; + if (thirdArgument === undefined) { + input = undefined; + inputProvided = false; + } else if (isServerTrackOptions(thirdArgument)) { + options = thirdArgument; + input = undefined; + inputProvided = false; + } else { + await applyValidationFailurePolicy( + new AnalyticsValidationError("invalid_options", eventName), + this.validation, + this.debug, + ); + return; + } + } + } else if (classification) { + const thirdArgument = argumentValues[2]; + if (thirdArgument === undefined) { + options = undefined; + } else if (isServerTrackOptions(thirdArgument)) { + options = thirdArgument; + } else { + await applyValidationFailurePolicy( + new AnalyticsValidationError("invalid_options", eventName), + this.validation, + this.debug, + ); + return; + } + } + + const resolved = await resolveEvent( + this.registry, + eventName, + input, + inputProvided, + this.validation, + this.debug, + ); + if (!resolved) return; + + await this.dispatchResolvedEvent(resolved, options); + } + + /** + * Replays an already-validated proxy event through the shared dispatch + * path. Internal entry point used by `ingestProxyEvents`; the replayed + * properties are the client's post-transform validator output, so they + * are resolved via `resolveReplayEvent` instead of being re-validated. + */ + async [serverAnalyticsReplay]( + eventName: string, + properties: unknown, + options: ServerTrackOptions | undefined, ): Promise { + if (!this.enabled) return; + if (!this.initialized) { console.warn("[Analytics] Not initialized. Call initialize() first."); return; } - const event: BaseEvent = { - action: eventName, - category: this.getCategoryFromEventName(eventName), - properties: properties as Record, + const resolved = await resolveReplayEvent( + this.registry, + eventName as EventName, + properties, + this.validation, + this.debug, + ); + if (!resolved) return; + + await this.dispatchResolvedEvent(resolved, options); + } + + /** + * Shared dispatch tail for track() and the proxy replay entry point: + * builds the BaseEvent, merges context, and fans out to providers. + */ + private async dispatchResolvedEvent>( + resolved: ResolvedEvent, + options: ServerTrackOptions | undefined, + ): Promise { + const event: BaseEvent[TName]> = { + action: resolved.name, + category: resolved.category, + properties: resolved.properties, timestamp: Date.now(), userId: options?.userId, sessionId: options?.sessionId, }; const context: EventContext = { - ...this.config.defaultContext, + ...this.defaultContext, ...options?.context, - user: options?.user || options?.context?.user, + user: + options?.user ?? options?.context?.user ?? this.defaultContext?.user, }; // Track with all providers in parallel (respecting method and event routing) @@ -487,11 +686,14 @@ export class ServerAnalytics< .filter( (config) => this.shouldCallMethod(config, "track") && - this.shouldTrackEvent(config, eventName), + this.shouldTrackEvent(config, resolved.name), ) .map(async (config) => { try { - await config.provider.track(event, context); + await config.provider.track( + event as BaseEvent, + context as EventContext, + ); } catch (error) { // Log error but don't throw - one provider failing shouldn't break others console.error( @@ -571,16 +773,19 @@ export class ServerAnalytics< context?: EventContext; }, ): Promise { + if (!this.enabled) return; if (!this.initialized) return; const context: EventContext = { - ...this.config.defaultContext, + ...this.defaultContext, ...options?.context, - } as EventContext; + }; const promises = this.providerConfigs .filter((config) => this.shouldCallMethod(config, "pageView")) - .map((config) => config.provider.pageView(properties, context)); + .map((config) => + config.provider.pageView(properties, context as EventContext), + ); const results = await Promise.allSettled(promises); @@ -656,19 +861,20 @@ export class ServerAnalytics< context?: EventContext; }, ): void { + if (!this.enabled) return; if (!this.initialized) return; const context: EventContext = { - ...this.config.defaultContext, + ...this.defaultContext, ...options?.context, - } as EventContext; + }; for (const config of this.providerConfigs) { if ( this.shouldCallMethod(config, "pageLeave") && config.provider.pageLeave ) { - config.provider.pageLeave(properties, context); + config.provider.pageLeave(properties, context as EventContext); } } } @@ -693,9 +899,25 @@ export class ServerAnalytics< * * @example * ```typescript + * import { defineEvents, typed } from 'trakoo'; + * import { ServerAnalytics } from 'trakoo/server'; + * + * const events = defineEvents({ + * functionCompleted: { + * name: 'function_completed', + * category: 'system', + * properties: typed<{ duration: number; success: boolean }>(), + * }, + * functionFailed: { + * name: 'function_failed', + * category: 'error', + * properties: typed<{ error: string; duration: number }>(), + * }, + * }); + * * // In a serverless function * export async function handler(event, context) { - * const analytics = new ServerAnalytics({ providers: [] }); + * const analytics = new ServerAnalytics({ events, providers: [] }); * analytics.initialize(); * * try { @@ -758,6 +980,8 @@ export class ServerAnalytics< * ``` */ async shutdown(): Promise { + if (!this.enabled) return; + // Shutdown all providers that support it (note: shutdown is not routable, always called) const shutdownPromises = this.providerConfigs.map((config) => { if ( @@ -771,15 +995,4 @@ export class ServerAnalytics< await Promise.all(shutdownPromises); } - - private getCategoryFromEventName(eventName: string): EventCategory { - // Extract category from event name pattern: category_action - const parts = eventName.split("_"); - // Only use the first part as category if there's actually an underscore - if (parts.length > 1 && parts[0]) { - return parts[0]; - } - - return "engagement"; // Default fallback category - } } diff --git a/src/client.ts b/src/client.ts index 8c40b13..1cb9e2e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,158 +1,44 @@ import { BrowserAnalytics } from "@/adapters/client/browser-analytics.js"; import type { - AnalyticsConfig, - ProviderConfigOrProvider, -} from "@/core/events/types.js"; -import type { EventMapFromCollection } from "@/core/events/index.js"; - -let analyticsInstance: BrowserAnalytics< - Record>, - Record -> | null = null; - -export interface ClientAnalyticsConfig { - providers?: ProviderConfigOrProvider[]; - debug?: boolean; - enabled?: boolean; + EventDefinitions, + EventRegistry, +} from "@/core/events/registry.js"; +import type { InferMarker, TypeMarker } from "@/core/events/schema.js"; +import type { ProviderConfigOrProvider } from "@/core/events/types.js"; +import type { ValidationConfig } from "@/core/events/validation.js"; + +type ClientUserTraits | undefined> = + M extends undefined ? Record : InferMarker; + +export interface ClientAnalyticsConfig< + R extends EventRegistry, + M extends TypeMarker | undefined = undefined, +> { + readonly events: R; + readonly userTraits?: M; + readonly providers?: ProviderConfigOrProvider[]; + readonly validation?: ValidationConfig; + readonly debug?: boolean; + readonly enabled?: boolean; } -/** - * Initialize analytics for the browser - * - * @example - * ```typescript - * import { createClientAnalytics } from 'trakoo/client'; - * import { PostHogClientProvider } from 'trakoo/providers/client'; - * import { AppEvents } from './events'; - * - * const analytics = createClientAnalytics({ - * providers: [ - * new PostHogClientProvider({ - * token: 'your-api-key', - * api_host: 'https://app.posthog.com' - * }) - * ], - * debug: true, - * enabled: true - * }); - * - * // Now event names and properties are fully typed! - * analytics.track('user_signed_up', { - * userId: 'user-123', - * email: 'user@example.com', - * plan: 'pro' - * }); - * ``` - */ export function createClientAnalytics< - TEvents = never, - TUserTraits extends Record = Record, + R extends EventRegistry, + M extends TypeMarker | undefined = undefined, >( - config: ClientAnalyticsConfig, -): BrowserAnalytics, TUserTraits> { - if (analyticsInstance) { - console.warn("[Analytics] Already initialized"); - return analyticsInstance as BrowserAnalytics< - EventMapFromCollection, - TUserTraits - >; - } - - const analyticsConfig: AnalyticsConfig = { - providers: config.providers || [], + config: ClientAnalyticsConfig, +): BrowserAnalytics> { + const analytics = new BrowserAnalytics>({ + events: config.events, + providers: config.providers ?? [], + validation: config.validation, debug: config.debug, enabled: config.enabled, - }; - - analyticsInstance = new BrowserAnalytics< - EventMapFromCollection, - TUserTraits - >(analyticsConfig) as BrowserAnalytics< - Record>, - Record - >; + }); - // Auto-initialize in the background without blocking - analyticsInstance.initialize().catch((error) => { + analytics.initialize().catch((error: unknown) => { console.error("[Analytics] Failed to initialize:", error); }); - return analyticsInstance as BrowserAnalytics< - EventMapFromCollection, - TUserTraits - >; -} - -// Convenience export for backwards compatibility -export { createClientAnalytics as createAnalytics }; - -/** - * Get the current analytics instance - */ -export function getAnalytics(): BrowserAnalytics< - Record>, - Record -> { - if (!analyticsInstance) { - throw new Error( - "[Analytics] Not initialized. Call createAnalytics() first.", - ); - } - return analyticsInstance; -} - -/** - * Convenience function to track events - */ -export function track( - eventName: string, - properties: Record, -): Promise { - return getAnalytics().track(eventName, properties); -} - -/** - * Convenience function to identify users - */ -export function identify( - userId: string, - traits?: Record, -): void { - getAnalytics().identify(userId, traits); -} - -/** - * Convenience function to track page views - */ -export function pageView(properties?: Record): void { - getAnalytics().pageView(properties); -} - -/** - * Convenience function to track page leave events - */ -export function pageLeave(properties?: Record): void { - getAnalytics().pageLeave(properties); -} - -/** - * Convenience function to reset user session - */ -export function reset(): void { - getAnalytics().reset(); -} - -/** - * Convenience function to flush queued events - */ -export function flush(useBeacon = false): Promise { - return getAnalytics().flush(useBeacon); -} - -/** - * Reset the analytics instance (for testing purposes) - * @internal - */ -export function resetAnalyticsInstance(): void { - analyticsInstance = null; + return analytics; } diff --git a/src/client/index.ts b/src/client/index.ts index 9501464..f920a88 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,16 +1,31 @@ // Main client analytics export { createClientAnalytics, - createAnalytics, - getAnalytics, - track, - identify, - pageView, - pageLeave, - reset, type ClientAnalyticsConfig, } from "@/client.js"; +export { defineEvents } from "@/core/events/registry.js"; +export type { + ClientTrackArgs, + EventInputMap, + EventName, + EventOutputMap, + EventRegistry, +} from "@/core/events/registry.js"; + +export { noProperties, typed } from "@/core/events/schema.js"; +export type { + NoPropertiesMarker, + TypeMarker, +} from "@/core/events/schema.js"; + +export { AnalyticsValidationError } from "@/core/events/validation.js"; +export type { + AnalyticsValidationErrorCode, + NormalizedValidationIssue, + ValidationConfig, +} from "@/core/events/validation.js"; + export { BrowserAnalytics } from "@/adapters/client/browser-analytics.js"; // Client-side providers @@ -22,18 +37,16 @@ export { BaseAnalyticsProvider } from "@/providers/base.provider.js"; // Type exports export type { - EventCategory, + AnalyticsConfig, + AnalyticsProvider, BaseEvent, + EventCategory, EventContext, - AnalyticsProvider, - AnalyticsConfig, + ProviderConfig, + ProviderConfigOrProvider, } from "@/core/events/types.js"; export type { - CreateEventDefinition, - ExtractEventNames, - ExtractEventPropertiesFromCollection, - EventCollection, AnyEventName, AnyEventProperties, } from "@/core/events/index.js"; diff --git a/src/core/events/index.ts b/src/core/events/index.ts index 0148bd4..8cc48e9 100644 --- a/src/core/events/index.ts +++ b/src/core/events/index.ts @@ -1,43 +1,8 @@ // Re-export core types export * from "./types.js"; - -// Generic type helpers for users to create their own strongly typed events -export type CreateEventDefinition< - TName extends string, - TProperties extends Record = Record, -> = { - name: TName; - category: import("./types.js").EventCategory; - properties: TProperties; -}; - -// Helper to extract event names from a collection of events -export type ExtractEventNames> = - T[keyof T]["name"]; - -// Helper to extract properties for a specific event -export type ExtractEventPropertiesFromCollection< - T extends Record< - string, - { name: string; properties: Record } - >, - TEventName extends ExtractEventNames, -> = Extract["properties"]; - -// Type for creating a collection of events -export type EventCollection< - T extends Record>, -> = T; - -export type EventMapFromCollection = T extends EventCollection - ? { - [K in keyof Events as Events[K] extends { name: infer N } - ? N extends string - ? N - : never - : never]: Events[K] extends { properties: infer P } ? P : never; - } - : Record>; +export * from "./schema.js"; +export * from "./registry.js"; +export * from "./validation.js"; // Generic types for any event system export type AnyEventName = string; diff --git a/src/core/events/registry.ts b/src/core/events/registry.ts new file mode 100644 index 0000000..cb843f0 --- /dev/null +++ b/src/core/events/registry.ts @@ -0,0 +1,141 @@ +import type { StandardSchemaV1, StandardTypedV1 } from "@standard-schema/spec"; +import type { EventCategory } from "./types.js"; +import { + classifyEventProperties, + type EventProperties, + type EventPropertiesClassification, + type NoPropertiesMarker, + type PropertyObject, +} from "./schema.js"; + +export interface RuntimeEventDefinition< + TName extends string = string, + TProperties extends EventProperties = EventProperties, +> { + readonly name: TName; + readonly category: EventCategory; + readonly properties: TProperties; +} + +export type EventDefinitions = Record; + +const registryBrand: unique symbol = Symbol("trakoo.eventRegistry"); + +const classificationsByRegistry = new WeakMap< + object, + ReadonlyMap +>(); + +export type EventRegistry = T & { + readonly [registryBrand]: ReadonlyMap; +}; + +type ObjectPropertySchema = + TProperties extends StandardSchemaV1 + ? PropertyObject extends never + ? never + : PropertyObject extends never + ? never + : TProperties + : TProperties; + +type ObjectPropertyDefinitions = { + readonly [K in keyof T]: { + readonly properties: ObjectPropertySchema; + }; +}; + +export function defineEvents( + definitions: T & ObjectPropertyDefinitions, +): EventRegistry { + const definitionsByName = new Map(); + const classificationsByName = new Map< + string, + EventPropertiesClassification + >(); + + for (const definition of Object.values(definitions)) { + if (definitionsByName.has(definition.name)) { + throw new Error(`Duplicate event name: ${definition.name}`); + } + definitionsByName.set(definition.name, definition); + // Classify once at definition time; classifyEventProperties is + // exception-safe, so hostile schemas surface as "access_failure". + classificationsByName.set( + definition.name, + classifyEventProperties(definition.properties), + ); + } + + Object.defineProperty(definitions, registryBrand, { + value: definitionsByName, + enumerable: false, + writable: false, + }); + classificationsByRegistry.set(definitions, classificationsByName); + + return definitions as EventRegistry; +} + +export function getEventDefinition( + registry: EventRegistry, + name: string, +): RuntimeEventDefinition | undefined { + return registry[registryBrand].get(name); +} + +/** @internal Returns the classification cached at defineEvents() time; undefined for unknown events. */ +export function getEventClassification( + registry: EventRegistry, + name: string, +): EventPropertiesClassification | undefined { + return classificationsByRegistry.get(registry)?.get(name); +} + +type RegistryDefinitions> = + R extends EventRegistry ? T : never; +type EventDefinitionOf> = + RegistryDefinitions[keyof RegistryDefinitions]; + +export type EventName> = + EventDefinitionOf["name"]; +type DefinitionForName< + R extends EventRegistry, + N extends EventName, +> = Extract, { name: N }>; +type PropertiesForName< + R extends EventRegistry, + N extends EventName, +> = DefinitionForName["properties"]; + +type InputFor = + TProperties extends NoPropertiesMarker + ? undefined + : StandardTypedV1.InferInput; +type OutputFor = + TProperties extends NoPropertiesMarker + ? Record + : StandardTypedV1.InferOutput; + +export type EventInputMap> = { + [N in EventName]: InputFor>; +}; + +export type EventOutputMap> = { + [N in EventName]: OutputFor>; +}; + +export type ClientTrackArgs< + R extends EventRegistry, + N extends EventName, +> = EventInputMap[N] extends undefined + ? [eventName: N] + : [eventName: N, properties: EventInputMap[N]]; + +export type ServerTrackArgs< + R extends EventRegistry, + N extends EventName, + O, +> = EventInputMap[N] extends undefined + ? [eventName: N] | [eventName: N, options: O] + : [eventName: N, properties: EventInputMap[N], options?: O]; diff --git a/src/core/events/schema.ts b/src/core/events/schema.ts new file mode 100644 index 0000000..c2adff4 --- /dev/null +++ b/src/core/events/schema.ts @@ -0,0 +1,123 @@ +import type { StandardSchemaV1, StandardTypedV1 } from "@standard-schema/spec"; + +declare const typeMarkerBrand: unique symbol; +declare const noPropertiesBrand: unique symbol; + +export type PropertyObject = T extends readonly unknown[] + ? never + : T extends (...args: never[]) => unknown + ? never + : T extends object + ? T + : never; + +type InvalidPropertyArguments = PropertyObject extends never + ? [error: "typed() requires a non-array, non-callable object shape"] + : []; + +export interface TypeMarker extends StandardTypedV1 { + readonly kind: "type"; + readonly [typeMarkerBrand]: T; +} + +export interface NoPropertiesMarker + extends StandardTypedV1> { + readonly kind: "none"; + readonly [noPropertiesBrand]: true; +} + +export function typed( + ..._invalid: InvalidPropertyArguments +): TypeMarker { + return Object.freeze({ + kind: "type", + "~standard": { version: 1, vendor: "trakoo" }, + }) as TypeMarker; +} + +export function noProperties(): NoPropertiesMarker { + return Object.freeze({ + kind: "none", + "~standard": { version: 1, vendor: "trakoo" }, + }) as NoPropertiesMarker; +} + +export type InferMarker = T extends TypeMarker + ? TValue + : Record; + +export type EventProperties = + | TypeMarker + | NoPropertiesMarker + | StandardSchemaV1; + +export type EventPropertiesClassification = + | { readonly kind: "type" } + | { readonly kind: "none" } + | { + readonly kind: "schema"; + readonly standard: StandardSchemaV1.Props; + readonly validate: StandardSchemaV1.Props["validate"]; + } + | { readonly kind: "invalid" } + | { readonly kind: "access_failure" }; + +/** @internal Classifies external definitions and captures schema accessors safely. */ +export function classifyEventProperties( + value: unknown, +): EventPropertiesClassification { + const isObject = typeof value === "object" && value !== null; + if (!isObject && typeof value !== "function") { + return { kind: "invalid" }; + } + + try { + if ("~standard" in value) { + const standard = value["~standard"]; + if ( + typeof standard === "object" && + standard !== null && + "validate" in standard + ) { + const validate = standard.validate as StandardSchemaV1.Props< + object, + object + >["validate"]; + if (typeof validate === "function") { + return { + kind: "schema", + standard: standard as StandardSchemaV1.Props, + validate, + }; + } + } + } + + if (!isObject) return { kind: "invalid" }; + + if ("kind" in value) { + if (value.kind === "type") return { kind: "type" }; + if (value.kind === "none") return { kind: "none" }; + } + + return { kind: "invalid" }; + } catch { + return { kind: "access_failure" }; + } +} + +export function isTypeMarker(value: unknown): value is TypeMarker { + return classifyEventProperties(value).kind === "type"; +} + +export function isNoPropertiesMarker( + value: unknown, +): value is NoPropertiesMarker { + return classifyEventProperties(value).kind === "none"; +} + +export function isStandardSchema( + value: unknown, +): value is StandardSchemaV1 { + return classifyEventProperties(value).kind === "schema"; +} diff --git a/src/core/events/types.ts b/src/core/events/types.ts index e007b1f..716d212 100644 --- a/src/core/events/types.ts +++ b/src/core/events/types.ts @@ -12,18 +12,18 @@ export type EventCategory = | PredefinedEventCategory | (string & Record); -export interface BaseEvent { +export interface BaseEvent< + TProperties extends object = Record, +> { category: EventCategory; action: string; timestamp?: number; userId?: string; sessionId?: string; - properties?: Record; + properties?: TProperties; } -export interface UserContext< - TTraits extends Record = Record, -> { +export interface UserContext> { userId?: string; email?: string; traits?: TTraits; @@ -42,7 +42,7 @@ export interface ServerContext { } export interface EventContext< - TTraits extends Record = Record, + TTraits extends object = Record, > { user?: UserContext; page?: { @@ -234,20 +234,3 @@ export interface AnalyticsConfig { enabled?: boolean; defaultContext?: Partial; } - -// Type helpers for creating strongly typed events -export type EventDefinition> = { - name: T; - category: EventCategory; - properties?: P; -}; - -export type ExtractEventName = T extends EventDefinition - ? N - : never; -export type ExtractEventProperties = T extends EventDefinition< - string, - infer P -> - ? P - : never; diff --git a/src/core/events/validation.ts b/src/core/events/validation.ts new file mode 100644 index 0000000..8918cd9 --- /dev/null +++ b/src/core/events/validation.ts @@ -0,0 +1,349 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { + getEventClassification, + getEventDefinition, + type EventDefinitions, + type EventName, + type EventOutputMap, + type EventRegistry, +} from "./registry.js"; +import { + classifyEventProperties, + type EventPropertiesClassification, +} from "./schema.js"; +import type { EventCategory } from "./types.js"; + +export type AnalyticsValidationErrorCode = + | "unknown_event" + | "invalid_properties" + | "invalid_options" + | "validator_failure" + | "invalid_output"; + +export interface NormalizedValidationIssue { + readonly message: string; + readonly path: readonly string[]; +} + +export interface ValidationConfig { + readonly onFailure?: "drop" | "throw"; + readonly onError?: (error: AnalyticsValidationError) => void; +} + +export class AnalyticsValidationError extends Error { + readonly name = "AnalyticsValidationError"; + constructor( + readonly code: AnalyticsValidationErrorCode, + readonly eventName: string, + readonly issues: readonly NormalizedValidationIssue[] = [], + ) { + super(`Analytics event ${eventName} failed: ${code}`); + } +} + +export interface ResolvedEvent< + R extends EventRegistry, + N extends EventName, +> { + readonly name: N; + readonly category: EventCategory; + readonly properties: EventOutputMap[N]; +} + +function isPropertyObject(value: unknown): value is object { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizePathSegment( + segment: PropertyKey | StandardSchemaV1.PathSegment, +): string { + let key: unknown = segment; + try { + if (typeof segment === "object" && segment !== null && "key" in segment) { + key = segment.key; + } + return String(key); + } catch { + return "[unknown]"; + } +} + +function normalizeIssues( + issues: readonly StandardSchemaV1.Issue[], +): readonly NormalizedValidationIssue[] { + return issues.map((issue) => { + let message = "Validation failed"; + let path: readonly string[] = []; + try { + message = String(issue.message); + } catch { + // Keep the fallback message so hostile issue accessors cannot escape validation. + } + try { + path = issue.path?.map(normalizePathSegment) ?? []; + } catch { + path = ["[unknown]"]; + } + return { message, path }; + }); +} + +/** @internal Shared adapter failure-policy entry point; not part of the public API. */ +export async function applyValidationFailurePolicy( + error: AnalyticsValidationError, + validation: ValidationConfig | undefined, + debug: boolean, +): Promise { + if (validation?.onError) { + try { + await validation.onError(error); + } catch { + // Error reporting must not change the configured tracking policy. + } + } else if (debug) { + try { + console.warn({ + code: error.code, + eventName: error.eventName, + paths: error.issues.map((issue) => issue.path), + }); + } catch { + // Debug logging must not change the configured tracking policy. + } + } + + if (validation?.onFailure === "throw") { + throw error; + } + return undefined; +} + +interface ClassifiedRegisteredEvent { + readonly category: EventCategory; + readonly classification: Exclude< + EventPropertiesClassification, + { kind: "access_failure" } + >; +} + +/** + * Shared registry lookup + classification plumbing for resolveEvent and + * resolveReplayEvent. Applies the failure policy (and returns undefined) + * for unknown events, hostile category/classification access, and + * access_failure classifications. + */ +async function classifyRegisteredEvent< + R extends EventRegistry, + N extends EventName, +>( + registry: R, + eventName: N, + validation: ValidationConfig | undefined, + debug: boolean, +): Promise { + const definition = getEventDefinition(registry, eventName); + if (!definition) { + return applyValidationFailurePolicy( + new AnalyticsValidationError("unknown_event", eventName), + validation, + debug, + ); + } + + let category: EventCategory; + let classification: EventPropertiesClassification; + try { + // A hostile getter can throw on the category read — this guard is + // load-bearing even though classification itself is cached. + category = definition.category; + classification = + getEventClassification(registry, eventName) ?? + classifyEventProperties(definition.properties); + } catch { + return applyValidationFailurePolicy( + new AnalyticsValidationError("validator_failure", eventName), + validation, + debug, + ); + } + + if (classification.kind === "access_failure") { + return applyValidationFailurePolicy( + new AnalyticsValidationError("validator_failure", eventName), + validation, + debug, + ); + } + + return { category, classification }; +} + +function failInvalidProperties( + eventName: string, + validation: ValidationConfig | undefined, + debug: boolean, +): Promise { + return applyValidationFailurePolicy( + new AnalyticsValidationError("invalid_properties", eventName), + validation, + debug, + ); +} + +export async function resolveEvent< + R extends EventRegistry, + N extends EventName, +>( + registry: R, + eventName: N, + input: unknown, + inputProvided: boolean, + validation: ValidationConfig | undefined, + debug: boolean, +): Promise | undefined> { + const classified = await classifyRegisteredEvent( + registry, + eventName, + validation, + debug, + ); + if (!classified) return undefined; + const { category, classification } = classified; + + if (classification.kind === "none") { + if (inputProvided) { + return failInvalidProperties(eventName, validation, debug); + } + return { + name: eventName, + category, + properties: {} as EventOutputMap[N], + }; + } + + if (classification.kind === "type") { + if (!isPropertyObject(input)) { + return failInvalidProperties(eventName, validation, debug); + } + return { + name: eventName, + category, + properties: input as EventOutputMap[N], + }; + } + + if (classification.kind === "invalid") { + return failInvalidProperties(eventName, validation, debug); + } + + let result: StandardSchemaV1.Result; + try { + result = await classification.validate.call(classification.standard, input); + } catch { + return applyValidationFailurePolicy( + new AnalyticsValidationError("validator_failure", eventName), + validation, + debug, + ); + } + + let failure: AnalyticsValidationError | undefined; + let output: object | undefined; + try { + if ("issues" in result) { + const issues = result.issues; + if (issues) { + failure = new AnalyticsValidationError( + "invalid_properties", + eventName, + normalizeIssues(issues), + ); + } + } + + if (!failure) { + if (!("value" in result)) { + failure = new AnalyticsValidationError("invalid_output", eventName); + } else { + const value = result.value; + if (isPropertyObject(value)) { + output = value; + } else { + failure = new AnalyticsValidationError("invalid_output", eventName); + } + } + } + } catch { + failure = new AnalyticsValidationError("validator_failure", eventName); + } + + if (failure) { + return applyValidationFailurePolicy(failure, validation, debug); + } + + return { + name: eventName, + category, + properties: output as EventOutputMap[N], + }; +} + +/** + * @internal Resolves a proxy-replayed event without re-running schema + * validation; not part of the public API. + * + * Replayed proxy properties are the client-validated POST-TRANSFORM output of + * the event's validator. Standard Schema validators only accept input, so + * re-validating that output on the server would reject any transforming + * schema (e.g. zod's `z.string().transform(Number)`). Schema-backed events + * therefore only get a structural property-object check here. + */ +export async function resolveReplayEvent< + R extends EventRegistry, + N extends EventName, +>( + registry: R, + eventName: N, + rawProperties: unknown, + validation: ValidationConfig | undefined, + debug: boolean, +): Promise | undefined> { + const classified = await classifyRegisteredEvent( + registry, + eventName, + validation, + debug, + ); + if (!classified) return undefined; + const { category, classification } = classified; + + if (classification.kind === "none") { + const isEmptyObject = + isPropertyObject(rawProperties) && + Object.keys(rawProperties).length === 0; + if (rawProperties !== undefined && !isEmptyObject) { + return failInvalidProperties(eventName, validation, debug); + } + return { + name: eventName, + category, + properties: {} as EventOutputMap[N], + }; + } + + if (classification.kind === "invalid") { + return failInvalidProperties(eventName, validation, debug); + } + + // kind "type" or "schema": pass the replayed properties through as-is. + // For "schema" this deliberately skips the validator — see the note above. + if (!isPropertyObject(rawProperties)) { + return failInvalidProperties(eventName, validation, debug); + } + + return { + name: eventName, + category, + properties: rawProperties as EventOutputMap[N], + }; +} diff --git a/src/index.ts b/src/index.ts index 4dc7097..7a1fa4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,41 @@ export type { - EventCategory, + AnalyticsConfig, + AnalyticsProvider, BaseEvent, + EventCategory, EventContext, - AnalyticsProvider, - AnalyticsConfig, + PredefinedEventCategory, + ProviderConfig, + ProviderConfigOrProvider, + ProviderMethod, + ServerContext, + UserContext, } from "@/core/events/types.js"; export type { - CreateEventDefinition, - ExtractEventNames, - ExtractEventPropertiesFromCollection, - EventCollection, AnyEventName, AnyEventProperties, - EventMapFromCollection, } from "@/core/events/index.js"; + +export { defineEvents } from "@/core/events/registry.js"; +export type { + ClientTrackArgs, + EventInputMap, + EventName, + EventOutputMap, + EventRegistry, + ServerTrackArgs, +} from "@/core/events/registry.js"; + +export { noProperties, typed } from "@/core/events/schema.js"; +export type { + NoPropertiesMarker, + TypeMarker, +} from "@/core/events/schema.js"; + +export { AnalyticsValidationError } from "@/core/events/validation.js"; +export type { + AnalyticsValidationErrorCode, + NormalizedValidationIssue, + ValidationConfig, +} from "@/core/events/validation.js"; diff --git a/src/providers/proxy/server.ts b/src/providers/proxy/server.ts index 8dff6a2..49a6514 100644 --- a/src/providers/proxy/server.ts +++ b/src/providers/proxy/server.ts @@ -1,5 +1,14 @@ import type { EventContext } from "@/core/events/types.js"; -import type { ServerAnalytics } from "@/server.js"; +import type { + EventDefinitions, + EventRegistry, +} from "@/core/events/registry.js"; +import { + serverAnalyticsReplay, + type ServerAnalytics, + type ServerAnalyticsReplayAccess, + type ServerTrackOptions, +} from "@/adapters/server/server-analytics.js"; import type { ProxyPayload } from "./types.js"; /** @@ -44,14 +53,11 @@ export interface IngestProxyEventsConfig { * ``` */ export async function ingestProxyEvents< - TEventMap extends Record> = Record< - string, - Record - >, - TUserTraits extends Record = Record, + TRegistry extends EventRegistry, + TUserTraits extends object = Record, >( request: Request, - analytics: ServerAnalytics, + analytics: ServerAnalytics, config?: IngestProxyEventsConfig, ): Promise { try { @@ -96,22 +102,27 @@ export async function ingestProxyEvents< }, } as EventContext; - // Convert BaseEvent back to track() parameters - await analytics.track( + const options: ServerTrackOptions = { + userId: event.event.userId, + sessionId: event.event.sessionId, + context: enrichedContext, + }; + + // Replay through the internal normalized entry point: the + // properties are already client-validated output, so they + // must not be re-validated by track(). + await ( + analytics as unknown as ServerAnalyticsReplayAccess + )[serverAnalyticsReplay]( event.event.action, - // biome-ignore lint/suspicious/noExplicitAny: Properties from JSON cannot be type-checked against TEventMap at compile time - event.event.properties as any, - { - userId: event.event.userId, - sessionId: event.event.sessionId, - context: enrichedContext, - }, + event.event.properties, + options, ); break; } case "identify": { - await analytics.identify(event.userId, event.traits); + await analytics.identify(event.userId, event.traits as TUserTraits); break; } @@ -207,13 +218,10 @@ function extractIpFromRequest(request: Request): string | undefined { * ``` */ export function createProxyHandler< - TEventMap extends Record> = Record< - string, - Record - >, - TUserTraits extends Record = Record, + TRegistry extends EventRegistry, + TUserTraits extends object = Record, >( - analytics: ServerAnalytics, + analytics: ServerAnalytics, config?: IngestProxyEventsConfig, ): (request: Request) => Promise { return async (request: Request) => { diff --git a/src/server.ts b/src/server.ts index 261e865..e5cb237 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,66 +1,52 @@ import { ServerAnalytics } from "@/adapters/server/server-analytics.js"; import type { - AnalyticsConfig, + EventDefinitions, + EventRegistry, +} from "@/core/events/registry.js"; +import type { InferMarker, TypeMarker } from "@/core/events/schema.js"; +import type { + EventContext, ProviderConfigOrProvider, } from "@/core/events/types.js"; -import type { - EventCollection, - EventMapFromCollection, -} from "@/core/events/index.js"; +import type { ValidationConfig } from "@/core/events/validation.js"; -export interface ServerAnalyticsConfig { - providers?: ProviderConfigOrProvider[]; - debug?: boolean; - enabled?: boolean; +type ServerUserTraits | undefined> = + M extends undefined ? Record : InferMarker; + +export interface ServerAnalyticsConfig< + R extends EventRegistry, + M extends TypeMarker | undefined = undefined, +> { + readonly events: R; + readonly userTraits?: M; + readonly providers?: ProviderConfigOrProvider[]; + readonly validation?: ValidationConfig; + readonly debug?: boolean; + readonly enabled?: boolean; + readonly defaultContext?: Partial>>; } /** - * Create a server analytics instance - * - * @example - * ```typescript - * import { createServerAnalytics } from 'trakoo/server'; - * import { PostHogServerProvider } from 'trakoo/providers/server'; - * import { AppEvents } from './events'; - * - * const analytics = createServerAnalytics({ - * providers: [ - * new PostHogServerProvider({ - * apiKey: process.env.POSTHOG_API_KEY, - * host: process.env.POSTHOG_HOST - * }) - * ], - * debug: true, - * enabled: true - * }); - * - * // Now event names and properties are fully typed! - * await analytics.track('user_signed_up', { - * userId: 'user-123', - * email: 'user@example.com', - * plan: 'pro' - * }, { userId: 'user-123' }); - * ``` + * Creates and initializes a fresh registry-bound server analytics instance. */ export function createServerAnalytics< - TEvents = never, - TUserTraits extends Record = Record, + R extends EventRegistry, + M extends TypeMarker | undefined = undefined, >( - config: ServerAnalyticsConfig, -): ServerAnalytics, TUserTraits> { - const analyticsConfig: AnalyticsConfig = { - providers: config.providers || [], + config: ServerAnalyticsConfig, +): ServerAnalytics> { + const analytics = new ServerAnalytics>({ + events: config.events, + providers: config.providers ?? [], + validation: config.validation, debug: config.debug, enabled: config.enabled, - }; - - const analytics = new ServerAnalytics< - EventMapFromCollection, - TUserTraits - >(analyticsConfig); + defaultContext: config.defaultContext, + }); analytics.initialize(); return analytics; } export { ServerAnalytics }; +export type { ServerTrackOptions } from "@/adapters/server/server-analytics.js"; diff --git a/src/server/index.ts b/src/server/index.ts index 3ad3ba3..dec721d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3,8 +3,31 @@ export { createServerAnalytics, ServerAnalytics, type ServerAnalyticsConfig, + type ServerTrackOptions, } from "@/server.js"; +export { defineEvents } from "@/core/events/registry.js"; +export type { + EventInputMap, + EventName, + EventOutputMap, + EventRegistry, + ServerTrackArgs, +} from "@/core/events/registry.js"; + +export { noProperties, typed } from "@/core/events/schema.js"; +export type { + NoPropertiesMarker, + TypeMarker, +} from "@/core/events/schema.js"; + +export { AnalyticsValidationError } from "@/core/events/validation.js"; +export type { + AnalyticsValidationErrorCode, + NormalizedValidationIssue, + ValidationConfig, +} from "@/core/events/validation.js"; + // Server-side providers export { PostHogServerProvider } from "@/providers/posthog/server.js"; export type { PostHogOptions } from "posthog-node"; @@ -14,18 +37,16 @@ export { BaseAnalyticsProvider } from "@/providers/base.provider.js"; // Type exports export type { - EventCategory, + AnalyticsConfig, + AnalyticsProvider, BaseEvent, + EventCategory, EventContext, - AnalyticsProvider, - AnalyticsConfig, + ProviderConfig, + ProviderConfigOrProvider, } from "@/core/events/types.js"; export type { - CreateEventDefinition, - ExtractEventNames, - ExtractEventPropertiesFromCollection, - EventCollection, AnyEventName, AnyEventProperties, } from "@/core/events/index.js"; diff --git a/test/client-analytics.test.ts b/test/client-analytics.test.ts index c100a88..cca0c73 100644 --- a/test/client-analytics.test.ts +++ b/test/client-analytics.test.ts @@ -1,17 +1,18 @@ /** * @vitest-environment jsdom */ -import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; -import { MockAnalyticsProvider } from "./mock-provider"; -import type { CreateEventDefinition, EventCollection } from "@/core/events"; +import { expect, expectTypeOf, beforeEach, describe, it, vi } from "vitest"; +import { z } from "zod"; +import type { BrowserAnalytics } from "@/adapters/client/browser-analytics"; import { - createClientAnalytics, - getAnalytics, - resetAnalyticsInstance, -} from "@/client"; -import { BrowserAnalytics } from "@/adapters/client/browser-analytics"; + type AnalyticsValidationError, + defineEvents, + noProperties, + typed, +} from "@/core/events"; +import { createClientAnalytics } from "@/client"; +import { MockAnalyticsProvider } from "./mock-provider"; -// Mock window.location Object.defineProperty(window, "location", { value: { pathname: "/test-page", @@ -20,122 +21,166 @@ Object.defineProperty(window, "location", { writable: true, }); -// Define test events -const TestEvents = { +interface UserTraits { + email?: string; + name?: string; + plan?: "free" | "pro"; +} + +const events = defineEvents({ pageViewed: { name: "page_viewed", category: "navigation", - properties: {} as { + properties: typed<{ path: string; title: string; referrer?: string; - }, + }>(), }, buttonClicked: { name: "button_clicked", category: "engagement", - properties: {} as { - buttonId: string; - label: string; - }, + properties: typed<{ buttonId: string; label: string }>(), + }, + testEvent: { + name: "test_event", + category: "custom-category", + properties: typed<{ test?: boolean; data?: string }>(), + }, + beforeReset: { + name: "before_reset", + category: "user", + properties: noProperties(), + }, + afterReset: { + name: "after_reset", + category: "user", + properties: noProperties(), + }, + sessionStarted: { + name: "session_started", + category: "user", + properties: noProperties(), }, -} as const satisfies EventCollection< - Record> ->; + normalized: { + name: "normalized_event", + category: "conversion", + properties: z + .object({ label: z.string() }) + .transform(({ label }) => ({ + normalizedLabel: label.trim().toLowerCase(), + })), + }, +}); + +type TestAnalytics = BrowserAnalytics; + +function assertClientTypes(): void { + const analytics = createClientAnalytics({ + events, + userTraits: typed(), + }); + + expectTypeOf(analytics).toEqualTypeOf(); + expectTypeOf(analytics.identify) + .parameter(1) + .toEqualTypeOf(); + + analytics.track("page_viewed", { + path: "/", + title: "Home", + }); + analytics.track("session_started"); + analytics.identify("user-123", { plan: "pro" }); + + // @ts-expect-error unknown event names are rejected + analytics.track("unknown_event", {}); + // @ts-expect-error properties are required for property-bearing events + analytics.track("page_viewed"); + // @ts-expect-error missing required event property + analytics.track("page_viewed", { path: "/" }); + // @ts-expect-error extra event properties are rejected + analytics.track("page_viewed", { path: "/", title: "Home", extra: true }); + // @ts-expect-error propertyless events accept exactly one argument + analytics.track("session_started", undefined); + // @ts-expect-error inferred user traits reject unknown properties + analytics.identify("user-123", { company: "Acme" }); +} + +void assertClientTypes; describe("Client Analytics", () => { let mockProvider: MockAnalyticsProvider; - let analytics: BrowserAnalytics; + let analytics: TestAnalytics; beforeEach(async () => { - // Reset singleton instance - resetAnalyticsInstance(); - - // Clear any previous mock calls mockProvider = new MockAnalyticsProvider({ debug: false, enabled: true }); - analytics = createClientAnalytics({ + events, + userTraits: typed(), providers: [mockProvider], + validation: { onFailure: "throw" }, debug: false, enabled: true, }); - - // Wait for initialization to complete await analytics.initialize(); }); - afterEach(() => { - vi.clearAllMocks(); - }); + it("returns a fresh initialized instance from each factory call", async () => { + const firstProvider = new MockAnalyticsProvider({ enabled: true }); + const secondProvider = new MockAnalyticsProvider({ enabled: true }); + const first = createClientAnalytics({ events, providers: [firstProvider] }); + const second = createClientAnalytics({ + events, + providers: [secondProvider], + }); - it("should initialize providers", () => { - expect(mockProvider.calls.initialize).toBe(1); + expect(first).not.toBe(second); + await Promise.all([first.initialize(), second.initialize()]); + expect(firstProvider.calls.initialize).toBe(1); + expect(secondProvider.calls.initialize).toBe(1); }); - it("should track events with browser context", async () => { - await analytics.track(TestEvents.pageViewed.name, { + it("tracks registry categories with browser and session context", async () => { + await analytics.track("page_viewed", { path: "/dashboard", title: "Dashboard", }); - expect(mockProvider.calls.track).toHaveLength(1); - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.event.action).toBe("page_viewed"); - expect(trackedEvent.event.category).toBe("page"); - expect(trackedEvent.context?.page?.path).toBe("/test-page"); + const tracked = mockProvider.calls.track[0]; + expect(tracked.event).toMatchObject({ + action: "page_viewed", + category: "navigation", + properties: { path: "/dashboard", title: "Dashboard" }, + }); + expect(tracked.event.sessionId).toMatch(/^\d+-[a-z0-9]{9}$/); + expect(tracked.context?.page?.path).toBe("/test-page"); }); - it("should generate session ID", async () => { - await analytics.track(TestEvents.buttonClicked.name, { - buttonId: "submit-btn", - label: "Submit", - }); + it("uses the exact definition category instead of deriving one", async () => { + await analytics.track("test_event", { test: true }); - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.event.sessionId).toBeDefined(); - expect(trackedEvent.event.sessionId).toMatch(/^\d+-[a-z0-9]{9}$/); + expect(mockProvider.calls.track[0].event.category).toBe("custom-category"); }); - it("should identify users and include userId in events", async () => { + it("identifies users with inferred ordinary-interface traits", async () => { analytics.identify("user-123", { email: "test@example.com", name: "Test User", + plan: "pro", }); + await analytics.track("test_event", { test: true }); - // Wait for async initialization - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(mockProvider.calls.identify).toHaveLength(1); expect(mockProvider.calls.identify[0]).toEqual({ userId: "user-123", traits: { email: "test@example.com", name: "Test User", + plan: "pro", }, }); - - // Track event after identify - await analytics.track("test_event", { test: true }); - - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.event.userId).toBe("user-123"); - }); - - it("should store user traits from identify and include in track context", async () => { - analytics.identify("user-123", { - email: "test@example.com", - name: "Test User", - plan: "pro", - }); - - // Wait for async initialization - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Track event after identify - await analytics.track("test_event", { test: true }); - - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.context?.user).toEqual({ + expect(mockProvider.calls.track[0].event.userId).toBe("user-123"); + expect(mockProvider.calls.track[0].context?.user).toEqual({ userId: "user-123", email: "test@example.com", traits: { @@ -146,204 +191,144 @@ describe("Client Analytics", () => { }); }); - it("should include user context when only userId is provided", async () => { - analytics.identify("user-456"); - - // Wait for async initialization - await new Promise((resolve) => setTimeout(resolve, 10)); - - await analytics.track("test_event", { test: true }); + it("tracks page views and updates context", async () => { + analytics.pageView({ customProp: "value" }); + await analytics.initialize(); - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.context?.user).toEqual({ - userId: "user-456", - email: undefined, - traits: undefined, + expect(mockProvider.calls.pageView[0]).toMatchObject({ + properties: { customProp: "value" }, }); + expect(mockProvider.calls.pageView[0].context?.page?.path).toBe( + "/test-page", + ); }); - it("should track page views with updated context", async () => { - analytics.pageView({ - customProp: "value", - }); + it("resets the session and clears user context", async () => { + analytics.identify("user-123", { email: "test@example.com" }); + await analytics.track("before_reset"); + const initialSessionId = mockProvider.calls.track[0].event.sessionId; - // Wait for async initialization - await new Promise((resolve) => setTimeout(resolve, 10)); + analytics.reset(); + await analytics.track("after_reset"); - expect(mockProvider.calls.pageView).toHaveLength(1); - const pageView = mockProvider.calls.pageView[0]; - expect(pageView.properties).toEqual({ - customProp: "value", - }); - expect(pageView.context?.page?.path).toBe("/test-page"); + expect(mockProvider.calls.reset).toBe(1); + expect(mockProvider.calls.track[1].event.sessionId).not.toBe( + initialSessionId, + ); + expect(mockProvider.calls.track[1].event.userId).toBeUndefined(); + expect(mockProvider.calls.track[1].context?.user).toBeUndefined(); }); - it("should reset user session", async () => { - // Identify user first - analytics.identify("user-123", { name: "Test" }); - - // Get initial session ID - await analytics.track("before_reset", {}); - - const beforeReset = mockProvider.calls.track[0]; - const initialSessionId = beforeReset.event.sessionId; + it("normalizes propertyless events to an empty properties object", async () => { + await analytics.track("session_started"); - // Reset - analytics.reset(); - expect(mockProvider.calls.reset).toBe(1); - - // Track after reset - await analytics.track("after_reset", {}); + expect(mockProvider.calls.track[0].event.properties).toEqual({}); + }); - const afterReset = mockProvider.calls.track[1]; + it("rejects an explicit undefined argument for a propertyless event", async () => { + const runtimeAnalytics = analytics as unknown as { + track(name: string, properties?: unknown): Promise; + }; - // Should have new session ID and no user ID - expect(afterReset.event.sessionId).not.toBe(initialSessionId); - expect(afterReset.event.userId).toBeUndefined(); + await expect( + runtimeAnalytics.track("session_started", undefined), + ).rejects.toMatchObject({ code: "invalid_properties" }); + expect(mockProvider.calls.track).toHaveLength(0); }); - it("should clear user traits on reset", async () => { - // Identify user with traits - analytics.identify("user-123", { - email: "test@example.com", - name: "Test User", + it("delivers one transformed output to every routed provider", async () => { + const first = new MockAnalyticsProvider({ enabled: true }); + const second = new MockAnalyticsProvider({ enabled: true }); + const transformed = createClientAnalytics({ + events, + providers: [first, second], }); + await transformed.initialize(); - // Wait for async initialization - await new Promise((resolve) => setTimeout(resolve, 10)); + await transformed.track("normalized_event", { label: " CHECKOUT " }); - // Track before reset - should have user context - await analytics.track("before_reset", {}); - const beforeReset = mockProvider.calls.track[0]; - expect(beforeReset.context?.user).toEqual({ - userId: "user-123", - email: "test@example.com", - traits: { - email: "test@example.com", - name: "Test User", - }, + expect(first.calls.track[0].event.properties).toEqual({ + normalizedLabel: "checkout", }); - - // Reset - analytics.reset(); - - // Track after reset - should have no user context - await analytics.track("after_reset", {}); - const afterReset = mockProvider.calls.track[1]; - expect(afterReset.context?.user).toBeUndefined(); - expect(afterReset.event.userId).toBeUndefined(); + expect(second.calls.track[0].event.properties).toBe( + first.calls.track[0].event.properties, + ); }); - it("should update context", async () => { - analytics.updateContext({ - utm: { - source: "google", - medium: "cpc", - name: "summer-sale", - }, - }); - - await analytics.track("test_event", {}); - - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.context?.utm).toEqual({ - source: "google", - medium: "cpc", - name: "summer-sale", + it("drops invalid properties by default after reporting once", async () => { + const onError = vi.fn(); + const dropped = createClientAnalytics({ + events, + providers: [mockProvider], + validation: { onError }, }); + await dropped.initialize(); + const runtimeAnalytics = dropped as unknown as { + track(name: string, properties?: unknown): Promise; + }; + + await expect( + runtimeAnalytics.track("page_viewed", null), + ).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledTimes(1); + expect(mockProvider.calls.track).toHaveLength(0); }); - it("should handle multiple providers", async () => { - // Since we already have an instance with mockProvider, let's test that multiple providers work - // by checking the initial setup - const mockProvider1 = new MockAnalyticsProvider({ enabled: true }); - const mockProvider2 = new MockAnalyticsProvider({ enabled: true }); + it("throws invalid properties when configured", async () => { + const runtimeAnalytics = analytics as unknown as { + track(name: string, properties?: unknown): Promise; + }; - // Reset modules to ensure clean state - vi.resetModules(); - const { createClientAnalytics: freshCreateClientAnalytics } = await import( - "../src/client" + await expect(runtimeAnalytics.track("page_viewed", null)).rejects.toEqual( + expect.objectContaining>({ + code: "invalid_properties", + eventName: "page_viewed", + }), ); - - const multiAnalytics = freshCreateClientAnalytics<{ - testEvent: { - name: "test_event"; - category: "engagement"; - properties: { test: boolean }; - }; - }>({ - providers: [mockProvider1, mockProvider2], - enabled: true, - }); - - // Wait for initialization - await multiAnalytics.initialize(); - - await multiAnalytics.track("test_event", { test: true }); - - // Both providers should have track calls - expect(mockProvider1.calls.track).toHaveLength(1); - expect(mockProvider2.calls.track).toHaveLength(1); + expect(mockProvider.calls.track).toHaveLength(0); }); - it("should auto-initialize when tracking before explicit initialization", async () => { - // Create analytics without initializing - const uninitializedProvider = new MockAnalyticsProvider({ enabled: true }); - const uninitializedAnalytics = new BrowserAnalytics({ - providers: [uninitializedProvider], + it("short-circuits disabled invalid events before lookup and delivery", async () => { + const provider = new MockAnalyticsProvider({ enabled: true }); + const onError = vi.fn(); + const disabled = createClientAnalytics({ + events, + providers: [provider], + enabled: false, + validation: { onFailure: "throw", onError }, }); - - // Track without explicit initialization - await uninitializedAnalytics.track("test_event", { data: "test" }); - - // Wait for auto-initialization - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Should have initialized the provider - expect(uninitializedProvider.calls.initialize).toBe(1); - - // And tracked the event - expect(uninitializedProvider.calls.track).toHaveLength(1); - expect(uninitializedProvider.calls.track[0].event.action).toBe( - "test_event", - ); + const runtimeAnalytics = disabled as unknown as { + track(name: string, properties?: unknown): Promise; + }; + + await expect( + runtimeAnalytics.track("not_registered", { secret: true }), + ).resolves.toBeUndefined(); + expect(provider.calls.initialize).toBe(0); + expect(provider.calls.track).toHaveLength(0); + expect(onError).not.toHaveBeenCalled(); }); - it("should extract category from event name", async () => { - await analytics.track("custom_action", { data: "test" }); - - const trackedEvent = mockProvider.calls.track[0]; - console.log("trackedEvent", trackedEvent); - expect(trackedEvent.event.category).toBe("custom"); - }); + it("updates context", async () => { + analytics.updateContext({ + utm: { source: "google", medium: "cpc", name: "summer-sale" }, + }); + await analytics.track("test_event", {}); - it("should use convenience functions", () => { - // These functions use the singleton instance - const analyticsInstance = getAnalytics(); - expect(analyticsInstance).toBe(analytics); + expect(mockProvider.calls.track[0].context?.utm).toEqual({ + source: "google", + medium: "cpc", + name: "summer-sale", + }); }); - it("should flush all providers", async () => { - // Track some events - await analytics.track("test_event", { data: "test" }); - - // Flush + it("flushes providers with the requested transport mode", async () => { await analytics.flush(); - - // Should have called flush on the provider - expect(mockProvider.calls.flush).toHaveLength(1); - expect(mockProvider.calls.flush[0].useBeacon).toBe(false); - }); - - it("should flush with beacon API", async () => { - // Track some events - await analytics.track("test_event", { data: "test" }); - - // Flush with beacon await analytics.flush(true); - // Should have called flush with beacon flag - expect(mockProvider.calls.flush).toHaveLength(1); - expect(mockProvider.calls.flush[0].useBeacon).toBe(true); + expect(mockProvider.calls.flush).toEqual([ + { useBeacon: false }, + { useBeacon: true }, + ]); }); }); diff --git a/test/client.test.ts b/test/client.test.ts index 8df94b5..b8cb597 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1,15 +1,64 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import * as Analytics from "@/client/index"; +import type { + AnalyticsValidationError, + EventInputMap, + EventOutputMap, + ValidationConfig, +} from "@/index"; +import * as Trakoo from "@/index"; + +// @ts-expect-error CreateEventDefinition is no longer public +import type { CreateEventDefinition } from "@/index"; +// @ts-expect-error EventCollection is no longer public +import type { EventCollection } from "@/index"; +// @ts-expect-error ExtractEventNames is no longer public +import type { ExtractEventNames } from "@/index"; +// @ts-expect-error ExtractEventPropertiesFromCollection is no longer public +import type { ExtractEventPropertiesFromCollection } from "@/index"; +// @ts-expect-error EventMapFromCollection is no longer public +import type { EventMapFromCollection } from "@/index"; +// @ts-expect-error EventDefinition is no longer public +import type { EventDefinition } from "@/index"; +// @ts-expect-error ExtractEventName is no longer public +import type { ExtractEventName } from "@/index"; +// @ts-expect-error ExtractEventProperties is no longer public +import type { ExtractEventProperties } from "@/index"; +// @ts-expect-error applyValidationFailurePolicy is internal +import { applyValidationFailurePolicy as rootFailurePolicy } from "@/index"; +// @ts-expect-error applyValidationFailurePolicy is internal +import { applyValidationFailurePolicy as clientFailurePolicy } from "@/client/index"; + +type PublicTypes = + | AnalyticsValidationError + | EventInputMap + | EventOutputMap + | ValidationConfig; + +void (0 as unknown as PublicTypes); +void rootFailurePolicy; +void clientFailurePolicy; describe("trakoo exports", () => { - it("should export client analytics functions", () => { + it("exports environment-neutral event helpers and validation errors", () => { + expect(Trakoo.defineEvents).toBeDefined(); + expect(Trakoo.typed).toBeDefined(); + expect(Trakoo.noProperties).toBeDefined(); + expect(Trakoo.AnalyticsValidationError).toBeDefined(); + expect(Trakoo).not.toHaveProperty("applyValidationFailurePolicy"); + expectTypeOf().not.toBeNever(); + }); + + it("should export only the registry-bound client factory", () => { expect(Analytics.createClientAnalytics).toBeDefined(); - expect(Analytics.getAnalytics).toBeDefined(); - expect(Analytics.track).toBeDefined(); - expect(Analytics.identify).toBeDefined(); - expect(Analytics.pageView).toBeDefined(); - expect(Analytics.pageLeave).toBeDefined(); - expect(Analytics.reset).toBeDefined(); + expect(Analytics).not.toHaveProperty("createAnalytics"); + expect(Analytics).not.toHaveProperty("getAnalytics"); + expect(Analytics).not.toHaveProperty("track"); + expect(Analytics).not.toHaveProperty("identify"); + expect(Analytics).not.toHaveProperty("pageView"); + expect(Analytics).not.toHaveProperty("pageLeave"); + expect(Analytics).not.toHaveProperty("reset"); + expect(Analytics).not.toHaveProperty("flush"); }); it("should export provider classes (client only)", () => { diff --git a/test/event-validation.test.ts b/test/event-validation.test.ts new file mode 100644 index 0000000..95b0fb4 --- /dev/null +++ b/test/event-validation.test.ts @@ -0,0 +1,708 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { + AnalyticsValidationError, + defineEvents, + noProperties, + resolveEvent, + typed, + type EventName, + isNoPropertiesMarker, + isStandardSchema, + isTypeMarker, +} from "@/core/events"; +import { describe, expect, expectTypeOf, it, vi } from "vitest"; + +const success = { value: { amount: 49 } }; +const failure = { issues: [{ message: "invalid", path: ["amount"] }] }; +const asyncSuccess = Promise.resolve({ value: { amount: 49 } }); + +function schema( + validate: StandardSchemaV1.Props["validate"], +): StandardSchemaV1 { + return { + "~standard": { + version: 1, + vendor: "trakoo-test", + validate, + }, + }; +} + +function callableSchema( + validate: StandardSchemaV1.Props["validate"], + kind?: "type" | "none", +): StandardSchemaV1 { + const carrier = Object.assign( + () => undefined, + { + "~standard": { + version: 1 as const, + vendor: "trakoo-test", + validate, + }, + }, + kind ? { kind } : {}, + ); + return carrier as unknown as StandardSchemaV1; +} + +function invalidResult( + value: unknown, +): StandardSchemaV1.Result { + return { value } as StandardSchemaV1.Result; +} + +function hostileSchemas(): readonly [string, StandardSchemaV1][] { + const throwingStandardGetter = Object.defineProperty({}, "~standard", { + get() { + throw new Error("standard getter retained secret-input"); + }, + }) as StandardSchemaV1; + const throwingValidateGetter = { + "~standard": Object.defineProperty( + { version: 1 as const, vendor: "trakoo-test" }, + "validate", + { + get() { + throw new Error("validate getter retained secret-input"); + }, + }, + ), + } as StandardSchemaV1; + const throwingHasTrap = new Proxy( + {}, + { + has() { + throw new Error("has trap retained secret-input"); + }, + }, + ) as StandardSchemaV1; + const throwingGetTrap = new Proxy( + {}, + { + has() { + return true; + }, + get() { + throw new Error("get trap retained secret-input"); + }, + }, + ) as StandardSchemaV1; + const throwingCallableStandardGetter = Object.defineProperty( + () => undefined, + "~standard", + { + get() { + throw new Error("callable standard getter retained secret-input"); + }, + }, + ) as unknown as StandardSchemaV1; + const throwingCallableHasTrap = new Proxy( + () => undefined, + { + has() { + throw new Error("callable has trap retained secret-input"); + }, + }, + ) as unknown as StandardSchemaV1; + const throwingCallableGetTrap = new Proxy( + () => undefined, + { + has() { + return true; + }, + get() { + throw new Error("callable get trap retained secret-input"); + }, + }, + ) as unknown as StandardSchemaV1; + + return [ + ["throwing ~standard getter", throwingStandardGetter], + ["throwing validate getter", throwingValidateGetter], + ["throwing Proxy has trap", throwingHasTrap], + ["throwing Proxy get trap", throwingGetTrap], + ["throwing callable ~standard getter", throwingCallableStandardGetter], + ["throwing callable Proxy has trap", throwingCallableHasTrap], + ["throwing callable Proxy get trap", throwingCallableGetTrap], + ]; +} + +const events = defineEvents({ + purchaseCompleted: { + name: "purchase_completed", + category: "conversion", + properties: schema<{ amount: string }, { amount: number }>(() => success), + }, + asyncPurchaseCompleted: { + name: "async_purchase_completed", + category: "conversion", + properties: schema<{ amount: string }, { amount: number }>( + () => asyncSuccess, + ), + }, + invalidPurchase: { + name: "invalid_purchase", + category: "conversion", + properties: schema<{ amount: string }, { amount: number }>(() => failure), + }, + throwingPurchase: { + name: "throwing_purchase", + category: "conversion", + properties: schema<{ amount: string }, { amount: number }>(() => { + throw new Error("validator retained secret-input"); + }), + }, + rejectingPurchase: { + name: "rejecting_purchase", + category: "conversion", + properties: schema<{ amount: string }, { amount: number }>(() => + Promise.reject(new Error("rejected secret-input")), + ), + }, + primitiveOutput: { + name: "primitive_output", + category: "conversion", + properties: schema(() => + invalidResult<{ amount: number }>(49), + ), + }, + nullOutput: { + name: "null_output", + category: "conversion", + properties: schema(() => + invalidResult<{ amount: number }>(null), + ), + }, + arrayOutput: { + name: "array_output", + category: "conversion", + properties: schema(() => + invalidResult<{ amount: number }>([49]), + ), + }, + typedEvent: { + name: "typed_event", + category: "engagement", + properties: typed<{ label: string }>(), + }, + sessionStarted: { + name: "session_started", + category: "user", + properties: noProperties(), + }, +}); + +type RuntimeEventName = EventName; + +async function rejectedValidationError( + promise: Promise, +): Promise { + const error = await promise.then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error).toBeInstanceOf(AnalyticsValidationError); + return error as AnalyticsValidationError; +} + +describe("resolveEvent", () => { + it.each([undefined, "type", "none"] as const)( + "validates a callable Standard Schema carrier with kind %s", + async (kind) => { + const validate = vi.fn((input: { amount: string }) => ({ + value: { amount: Number(input.amount), validated: true }, + })); + const callableEvents = defineEvents({ + purchase: { + name: "callable_purchase", + category: "conversion", + properties: callableSchema(validate, kind), + }, + }); + const input = { amount: "49" }; + + await expect( + resolveEvent( + callableEvents, + "callable_purchase", + input, + true, + undefined, + false, + ), + ).resolves.toMatchObject({ + properties: { amount: 49, validated: true }, + }); + expect(validate).toHaveBeenCalledOnce(); + expect(validate).toHaveBeenCalledWith(input); + }, + ); + + it.each(["type", "none"] as const)( + "does not treat a bare callable with kind %s as a trakoo marker", + (kind) => { + const callable = Object.assign(() => undefined, { kind }); + + expect(isTypeMarker(callable)).toBe(false); + expect(isNoPropertiesMarker(callable)).toBe(false); + expect(isStandardSchema(callable)).toBe(false); + }, + ); + + it.each(["type", "none"] as const)( + "prefers a callable Standard Schema validator over kind %s", + async (kind) => { + const validate = vi.fn((input: { amount: string }) => ({ + value: { amount: Number(input.amount), validated: true }, + })); + const collidingEvents = defineEvents({ + purchase: { + name: "colliding_purchase", + category: "conversion", + properties: Object.assign( + schema< + { amount: string }, + { amount: number; validated: boolean } + >(validate), + { kind }, + ), + }, + }); + const input = { amount: "49" }; + + await expect( + resolveEvent( + collidingEvents, + "colliding_purchase", + input, + true, + undefined, + false, + ), + ).resolves.toMatchObject({ + properties: { amount: 49, validated: true }, + }); + expect(validate).toHaveBeenCalledOnce(); + expect(validate).toHaveBeenCalledWith(input); + }, + ); + + it.each(hostileSchemas())( + "routes a %s through the default validator failure policy", + async (_description, properties) => { + const hostileEvents = defineEvents({ + hostile: { + name: "hostile_event", + category: "system", + properties, + }, + }); + const onError = vi.fn(); + + await expect( + resolveEvent( + hostileEvents, + "hostile_event", + { value: "secret-input" }, + true, + { onError }, + false, + ), + ).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "validator_failure", issues: [] }), + ); + }, + ); + + it.each(hostileSchemas())( + "sanitizes strict failures from a %s", + async (_description, properties) => { + const hostileEvents = defineEvents({ + hostile: { + name: "hostile_event", + category: "system", + properties, + }, + }); + + const error = await rejectedValidationError( + resolveEvent( + hostileEvents, + "hostile_event", + { value: "secret-input" }, + true, + { onFailure: "throw" }, + false, + ), + ); + + expect(error).toMatchObject({ + code: "validator_failure", + eventName: "hostile_event", + issues: [], + }); + expect("input" in error).toBe(false); + expect("payload" in error).toBe(false); + expect("cause" in error).toBe(false); + expect(JSON.stringify(error)).not.toContain("secret"); + }, + ); + + it.each(hostileSchemas())( + "keeps public guards exception-safe for a %s", + (_description, properties) => { + expect(isNoPropertiesMarker(properties)).toBe(false); + expect(isTypeMarker(properties)).toBe(false); + expect(isStandardSchema(properties)).toBe(false); + }, + ); + + it("returns transformed output from a synchronous Standard Schema validator", async () => { + const resolved = await resolveEvent( + events, + "purchase_completed", + { amount: "49" }, + true, + undefined, + false, + ); + + expect(resolved).toEqual({ + name: "purchase_completed", + category: "conversion", + properties: { amount: 49 }, + }); + expectTypeOf(resolved?.properties).toEqualTypeOf< + { amount: number } | undefined + >(); + }); + + it("awaits successful asynchronous validation", async () => { + await expect( + resolveEvent( + events, + "async_purchase_completed", + { amount: "49" }, + true, + undefined, + false, + ), + ).resolves.toMatchObject({ properties: { amount: 49 } }); + }); + + it("does not resolve until a deferred validator finishes", async () => { + let finishValidation: + | ((result: StandardSchemaV1.Result<{ amount: number }>) => void) + | undefined; + const deferred = new Promise>( + (resolve) => { + finishValidation = resolve; + }, + ); + const deferredEvents = defineEvents({ + purchase: { + name: "deferred_purchase", + category: "conversion", + properties: schema<{ amount: string }, { amount: number }>( + () => deferred, + ), + }, + }); + let settled = false; + const resolution = resolveEvent( + deferredEvents, + "deferred_purchase", + { amount: "49" }, + true, + undefined, + false, + ).then((result) => { + settled = true; + return result; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + finishValidation?.(success); + await expect(resolution).resolves.toMatchObject({ + properties: { amount: 49 }, + }); + }); + + it("normalizes returned validation issues", async () => { + const symbolKey = Symbol("private"); + const issueEvents = defineEvents({ + purchase: { + name: "path_purchase", + category: "conversion", + properties: schema(() => ({ + issues: [ + { + message: "invalid", + path: ["amount", 0, symbolKey, { key: "nested" }], + }, + ], + })), + }, + }); + + const error = await rejectedValidationError( + resolveEvent( + issueEvents, + "path_purchase", + {}, + true, + { onFailure: "throw" }, + false, + ), + ); + + expect(error).toMatchObject({ + code: "invalid_properties", + eventName: "path_purchase", + issues: [ + { + message: "invalid", + path: ["amount", "0", "Symbol(private)", "nested"], + }, + ], + }); + }); + + it.each(["throwing_purchase", "rejecting_purchase"] as const)( + "maps a failed %s validator to validator_failure", + async (eventName) => { + const error = await rejectedValidationError( + resolveEvent( + events, + eventName, + { amount: "secret-input" }, + true, + { onFailure: "throw" }, + false, + ), + ); + + expect(error).toMatchObject({ code: "validator_failure", issues: [] }); + }, + ); + + it.each(["primitive_output", "null_output", "array_output"] as const)( + "rejects non-property-object output from %s", + async (eventName) => { + const error = await rejectedValidationError( + resolveEvent( + events, + eventName, + {}, + true, + { onFailure: "throw" }, + false, + ), + ); + + expect(error.code).toBe("invalid_output"); + }, + ); + + it("accepts only non-null, non-array objects for type markers", async () => { + const properties = { label: "Sign up" }; + await expect( + resolveEvent(events, "typed_event", properties, true, undefined, false), + ).resolves.toMatchObject({ properties }); + + for (const input of [undefined, null, "label", [properties]]) { + const error = await rejectedValidationError( + resolveEvent( + events, + "typed_event", + input, + true, + { onFailure: "throw" }, + false, + ), + ); + expect(error.code).toBe("invalid_properties"); + } + }); + + it("normalizes omitted propertyless input to an empty object", async () => { + await expect( + resolveEvent( + events, + "session_started", + undefined, + false, + undefined, + false, + ), + ).resolves.toEqual({ + name: "session_started", + category: "user", + properties: {}, + }); + }); + + it.each([undefined, {}, null])( + "rejects explicitly supplied propertyless input %#", + async (input) => { + const error = await rejectedValidationError( + resolveEvent( + events, + "session_started", + input, + true, + { onFailure: "throw" }, + false, + ), + ); + expect(error.code).toBe("invalid_properties"); + }, + ); + + it("drops unknown event names by default", async () => { + await expect( + resolveEvent( + events, + "missing_event" as RuntimeEventName, + {}, + true, + undefined, + false, + ), + ).resolves.toBeUndefined(); + }); + + it("throws normalized errors only when explicitly configured", async () => { + const error = await rejectedValidationError( + resolveEvent( + events, + "invalid_purchase", + { amount: "invalid" }, + true, + { onFailure: "throw" }, + false, + ), + ); + + expect(error).toMatchObject({ + name: "AnalyticsValidationError", + code: "invalid_properties", + eventName: "invalid_purchase", + issues: [{ message: "invalid", path: ["amount"] }], + }); + expect(error.message).toBe( + "Analytics event invalid_purchase failed: invalid_properties", + ); + }); + + it("invokes onError exactly once before applying the drop policy", async () => { + const onError = vi.fn(); + + await expect( + resolveEvent( + events, + "invalid_purchase", + { amount: "invalid" }, + true, + { onError }, + false, + ), + ).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "invalid_properties" }), + ); + }); + + it("contains throwing and rejected onError callbacks", async () => { + const throwingCallback = vi.fn(() => { + throw new Error("callback failure"); + }); + const rejectedCallback = vi.fn(async () => { + throw new Error("async callback failure"); + }); + + await expect( + resolveEvent( + events, + "invalid_purchase", + {}, + true, + { onError: throwingCallback }, + false, + ), + ).resolves.toBeUndefined(); + const error = await rejectedValidationError( + resolveEvent( + events, + "invalid_purchase", + {}, + true, + { onFailure: "throw", onError: rejectedCallback }, + false, + ), + ); + + expect(throwingCallback).toHaveBeenCalledOnce(); + expect(rejectedCallback).toHaveBeenCalledOnce(); + expect(error.code).toBe("invalid_properties"); + }); + + it("logs only sanitized metadata when debug fallback is enabled", async () => { + const debugEvents = defineEvents({ + purchase: { + name: "debug_purchase", + category: "conversion", + properties: schema(() => ({ + issues: [ + { + message: "invalid secret-message", + path: ["amount"], + }, + ], + })), + }, + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await resolveEvent( + debugEvents, + "debug_purchase", + { amount: "secret-input" }, + true, + undefined, + true, + ); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith({ + code: "invalid_properties", + eventName: "debug_purchase", + paths: [["amount"]], + }); + expect(JSON.stringify(warn.mock.calls)).not.toContain("secret"); + warn.mockRestore(); + }); + + it("never retains input, payload, or validator exceptions on errors", async () => { + const error = await rejectedValidationError( + resolveEvent( + events, + "throwing_purchase", + { amount: "secret-input" }, + true, + { onFailure: "throw" }, + false, + ), + ); + + expect("input" in error).toBe(false); + expect("payload" in error).toBe(false); + expect("cause" in error).toBe(false); + expect(JSON.stringify(error)).not.toContain("secret-input"); + }); +}); diff --git a/test/events.test.ts b/test/events.test.ts index 358a39f..fe29e89 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -1,109 +1,206 @@ -import { describe, it, expect, expectTypeOf } from "vitest"; -import type { - CreateEventDefinition, - EventCollection, - ExtractEventNames, - ExtractEventPropertiesFromCollection, - PredefinedEventCategory, +import { + defineEvents, + getEventDefinition, + isNoPropertiesMarker, + isStandardSchema, + isTypeMarker, + noProperties, + typed, + type ClientTrackArgs, + type EventInputMap, + type EventName, + type EventOutputMap, + type ServerTrackArgs, } from "@/core/events"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { z } from "zod"; -describe("Event Types", () => { - it("should allow creating event definitions with type safety", () => { - const testEvent: CreateEventDefinition<"test_event", { userId: string }> = { - name: "test_event", - category: "user", - properties: { userId: "123" }, - }; +interface ClickProperties { + buttonId: string; + location?: string; +} - expect(testEvent.name).toBe("test_event"); - expect(testEvent.category).toBe("user"); - expect(testEvent.properties.userId).toBe("123"); - }); +const events = defineEvents({ + buttonClicked: { + name: "button_clicked", + category: "engagement", + properties: typed(), + }, + sessionStarted: { + name: "session_started", + category: "user", + properties: noProperties(), + }, + formSubmitted: { + name: "form_submitted", + category: "conversion", + properties: z + .object({ rawValue: z.string() }) + .transform(({ rawValue }) => ({ normalizedValue: rawValue.trim() })), + }, +}); - it("should allow custom event categories", () => { - const customEvent: CreateEventDefinition<"custom_event"> = { - name: "custom_event", - category: "custom_category", // Custom category - properties: {}, - }; +describe("event schema markers", () => { + it("preserves event names, property types, and literal categories", () => { + expectTypeOf>().toEqualTypeOf< + "button_clicked" | "session_started" | "form_submitted" + >(); + expectTypeOf["button_clicked"]>().toEqualTypeOf(); + expectTypeOf["button_clicked"]>().toEqualTypeOf(); + expectTypeOf["session_started"]>().toEqualTypeOf(); + expectTypeOf["session_started"]>().toEqualTypeOf< + Record + >(); + expectTypeOf["form_submitted"]>().toEqualTypeOf<{ + rawValue: string; + }>(); + expectTypeOf["form_submitted"]>().toEqualTypeOf<{ + normalizedValue: string; + }>(); + expectTypeOf<(typeof events)["buttonClicked"]["category"]>().toEqualTypeOf<"engagement">(); + }); - expect(customEvent.category).toBe("custom_category"); + it("provides readable client and server track tuples", () => { + expectTypeOf>().toEqualTypeOf< + [eventName: "button_clicked", properties: ClickProperties] + >(); + expectTypeOf>().toEqualTypeOf< + [eventName: "session_started"] + >(); + expectTypeOf< + ServerTrackArgs + >().toEqualTypeOf< + [ + eventName: "button_clicked", + properties: ClickProperties, + options?: { flush?: boolean }, + ] + >(); + expectTypeOf< + ServerTrackArgs + >().toEqualTypeOf< + | [eventName: "session_started"] + | [eventName: "session_started", options: { flush?: boolean }] + >(); }); - it("should extract event names from collection", () => { - const TestEvents = { - userSignedUp: { - name: "user_signed_up", - category: "user", - properties: {} as { userId: string }, - }, - pageViewed: { - name: "page_viewed", - category: "navigation", - properties: {} as { path: string }, - }, - } as const satisfies EventCollection< - Record> - >; + it("creates frozen Standard Typed marker objects without validation", () => { + const typeMarker = typed(); + const noneMarker = noProperties(); + + expect(typeMarker).toEqual({ + kind: "type", + "~standard": { version: 1, vendor: "trakoo" }, + }); + expect(noneMarker).toEqual({ + kind: "none", + "~standard": { version: 1, vendor: "trakoo" }, + }); + expect(Object.isFrozen(typeMarker)).toBe(true); + expect(Object.isFrozen(noneMarker)).toBe(true); + expect("validate" in typeMarker).toBe(false); + expect("validate" in noneMarker).toBe(false); + }); - type EventNames = ExtractEventNames; + it("recognizes markers and Standard Schema values", () => { + expect(isTypeMarker(typed())).toBe(true); + expect(isTypeMarker(noProperties())).toBe(false); + expect(isNoPropertiesMarker(noProperties())).toBe(true); + expect(isNoPropertiesMarker(typed())).toBe(false); + expect(isStandardSchema(z.object({ value: z.string() }))).toBe(true); + expect(isStandardSchema(typed())).toBe(false); + expect(isStandardSchema({})).toBe(false); + }); +}); - // Type tests - expectTypeOf().toEqualTypeOf< - "user_signed_up" | "page_viewed" - >(); +describe("event registry", () => { + it("rejects duplicate emitted event names", () => { + expect(() => + defineEvents({ + first: { + name: "duplicate", + category: "engagement", + properties: noProperties(), + }, + second: { + name: "duplicate", + category: "user", + properties: noProperties(), + }, + }), + ).toThrow(/duplicate event name/i); }); - it("should extract event properties from collection", () => { - const TestEvents = { - userSignedUp: { - name: "user_signed_up", - category: "user", - properties: {} as { userId: string; email: string }, - }, - pageViewed: { - name: "page_viewed", - category: "navigation", - properties: {} as { path: string; title?: string }, - }, - } as const satisfies EventCollection< - Record> - >; + it("stores private registry metadata as a non-enumerable property", () => { + expect(Object.keys(events)).toEqual([ + "buttonClicked", + "sessionStarted", + "formSubmitted", + ]); - type SignUpProps = ExtractEventPropertiesFromCollection< - typeof TestEvents, - "user_signed_up" - >; - type PageProps = ExtractEventPropertiesFromCollection< - typeof TestEvents, - "page_viewed" - >; + const registrySymbol = Reflect.ownKeys(events).find( + (key): key is symbol => typeof key === "symbol", + ); + expect(registrySymbol).toBeTypeOf("symbol"); + expect(Object.getOwnPropertyDescriptor(events, registrySymbol as symbol)).toMatchObject({ + enumerable: false, + writable: false, + }); + }); - // Type tests - expectTypeOf().toEqualTypeOf<{ - userId: string; - email: string; - }>(); - expectTypeOf().toEqualTypeOf<{ path: string; title?: string }>(); + it("looks up event definitions by emitted name", () => { + expect(getEventDefinition(events, "button_clicked")).toBe(events.buttonClicked); + expect(getEventDefinition(events, "missing_event")).toBeUndefined(); }); +}); - it("should have predefined event categories", () => { - const categories: PredefinedEventCategory[] = [ - "user", - "navigation", - "error", - "performance", - "conversion", - "engagement", - ]; +// @ts-expect-error typed() rejects primitive property types +typed(); +// @ts-expect-error typed() rejects array property types +typed(); +// @ts-expect-error typed() rejects callable property types +typed<() => void>(); - for (const category of categories) { - const event: CreateEventDefinition<"test"> = { - name: "test", - category, - properties: {}, - }; - expect(event.category).toBe(category); - } - }); +// Standard Schema event properties must have object inputs and outputs. +// @ts-expect-error primitive Standard Schema input is rejected +defineEvents({ invalid: { name: "invalid", category: "user", properties: z.string() } }); +defineEvents({ + invalid: { + name: "invalid", + category: "user", + // @ts-expect-error array Standard Schema input is rejected + properties: z.array(z.string()), + }, +}); +defineEvents({ + invalid: { + name: "invalid", + category: "user", + // @ts-expect-error callable Standard Schema input is rejected + properties: z.custom<() => void>(), + }, +}); +defineEvents({ + invalid: { + name: "invalid", + category: "user", + // @ts-expect-error primitive Standard Schema output is rejected + properties: z.object({ value: z.string() }).transform(({ value }) => value), + }, +}); +defineEvents({ + invalid: { + name: "invalid", + category: "user", + // @ts-expect-error array Standard Schema output is rejected + properties: z.object({ value: z.string() }).transform(({ value }) => [value]), + }, +}); +defineEvents({ + invalid: { + name: "invalid", + category: "user", + // @ts-expect-error callable Standard Schema output is rejected + properties: z.object({ value: z.string() }).transform(() => () => undefined), + }, }); diff --git a/test/fixtures/invalid-event-usage.ts b/test/fixtures/invalid-event-usage.ts new file mode 100644 index 0000000..9111a45 --- /dev/null +++ b/test/fixtures/invalid-event-usage.ts @@ -0,0 +1,20 @@ +import { defineEvents, typed } from "../../src/index.js"; +import { createClientAnalytics } from "../../src/client/index.js"; + +const events = defineEvents({ + purchaseCompleted: { + name: "purchase_completed", + category: "conversion", + properties: typed<{ orderId: string; amount: string }>(), + }, +}); + +const analytics = createClientAnalytics({ events }); + +analytics.track("purchase_compeleted", { + orderId: "order_1", + amount: "49", +}); +analytics.track("purchase_completed", { + total: "49", +}); diff --git a/test/package-verification.test.ts b/test/package-verification.test.ts new file mode 100644 index 0000000..7f11339 --- /dev/null +++ b/test/package-verification.test.ts @@ -0,0 +1,49 @@ +import { + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { assertRootBundleNeutral } from "../scripts/package-verification.mjs"; + +describe("packed root bundle verification", () => { + it("scans only the complete root-reachable static import graph", () => { + const distDirectory = mkdtempSync( + join(tmpdir(), "trakoo-root-graph-"), + ); + try { + mkdirSync(join(distDirectory, "chunks")); + writeFileSync( + join(distDirectory, "index.js"), + 'import"./chunks/registry.js";', + ); + writeFileSync( + join(distDirectory, "chunks/registry.js"), + 'import"../index.js";export*from"./validation.js";', + ); + writeFileSync( + join(distDirectory, "chunks/validation.js"), + 'import "@bentonow/bento-node-sdk";', + ); + writeFileSync( + join(distDirectory, "providers.js"), + 'import "zod";', + ); + + expect(() => + assertRootBundleNeutral( + join(distDirectory, "index.js"), + distDirectory, + ["zod", "@bentonow/bento-node-sdk"], + ), + ).toThrow( + "root bundle includes @bentonow/bento-node-sdk in chunks/validation.js", + ); + } finally { + rmSync(distDirectory, { recursive: true, force: true }); + } + }); +}); diff --git a/test/provider-routing.test.ts b/test/provider-routing.test.ts index 5ce7b53..d8dc624 100644 --- a/test/provider-routing.test.ts +++ b/test/provider-routing.test.ts @@ -3,7 +3,8 @@ */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { MockAnalyticsProvider } from "./mock-provider"; -import { createClientAnalytics, resetAnalyticsInstance } from "@/client"; +import { createClientAnalytics } from "@/client"; +import { defineEvents, typed } from "@/core/events"; import { createServerAnalytics } from "@/server"; import type { BrowserAnalytics } from "@/adapters/client/browser-analytics"; import type { ServerAnalytics } from "@/adapters/server/server-analytics"; @@ -17,14 +18,46 @@ Object.defineProperty(window, "location", { writable: true, }); +const clientRoutingEvents = defineEvents({ + testEvent: { + name: "test_event", + category: "engagement", + properties: typed<{ foo: string }>(), + }, + newsletterSignup: { + name: "newsletter_signup", + category: "conversion", + properties: typed<{ email: string }>(), + }, + newsletterUnsubscribe: { + name: "newsletter_unsubscribe", + category: "conversion", + properties: typed<{ email: string }>(), + }, + userRegistered: { + name: "user_registered", + category: "user", + properties: typed<{ userId: string }>(), + }, + pageViewed: { + name: "page_viewed", + category: "navigation", + properties: typed<{ path: string }>(), + }, + buttonClicked: { + name: "button_clicked", + category: "engagement", + properties: typed<{ buttonId: string }>(), + }, +}); + describe("Provider Routing - Client", () => { let provider1: MockAnalyticsProvider; let provider2: MockAnalyticsProvider; let provider3: MockAnalyticsProvider; - let analytics: BrowserAnalytics; + let analytics: BrowserAnalytics; beforeEach(() => { - resetAnalyticsInstance(); provider1 = new MockAnalyticsProvider({ debug: false, enabled: true }); provider2 = new MockAnalyticsProvider({ debug: false, enabled: true }); provider3 = new MockAnalyticsProvider({ debug: false, enabled: true }); @@ -41,6 +74,7 @@ describe("Provider Routing - Client", () => { it("should call all methods on simple provider (default behavior)", async () => { analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [provider1], }); @@ -61,6 +95,7 @@ describe("Provider Routing - Client", () => { it("should only call specified methods with 'methods' option", async () => { analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -89,6 +124,7 @@ describe("Provider Routing - Client", () => { it("should skip specified methods with 'exclude' option", async () => { analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -117,6 +153,7 @@ describe("Provider Routing - Client", () => { it("should handle mixed provider configurations", async () => { analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ // Simple provider - gets all methods provider1, @@ -170,6 +207,7 @@ describe("Provider Routing - Client", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -198,6 +236,7 @@ describe("Provider Routing - Client", () => { it("should handle empty 'methods' array", async () => { analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -220,6 +259,7 @@ describe("Provider Routing - Client", () => { it("should handle empty 'exclude' array", async () => { analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -245,7 +285,7 @@ describe("Provider Routing - Server", () => { let provider1: MockAnalyticsProvider; let provider2: MockAnalyticsProvider; let provider3: MockAnalyticsProvider; - let analytics: ReturnType; + let analytics: ServerAnalytics; beforeEach(() => { provider1 = new MockAnalyticsProvider({ debug: false, enabled: true }); @@ -264,6 +304,7 @@ describe("Provider Routing - Server", () => { it("should call all methods on simple provider (default behavior)", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [provider1], }); @@ -282,6 +323,7 @@ describe("Provider Routing - Server", () => { it("should only call specified methods with 'methods' option", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -308,6 +350,7 @@ describe("Provider Routing - Server", () => { it("should skip specified methods with 'exclude' option", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -334,6 +377,7 @@ describe("Provider Routing - Server", () => { it("should handle mixed provider configurations", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ // Simple provider - gets all methods provider1, @@ -383,10 +427,9 @@ describe("Event-Level Routing - Client", () => { let provider1: MockAnalyticsProvider; let provider2: MockAnalyticsProvider; let provider3: MockAnalyticsProvider; - let analytics: BrowserAnalytics; + let analytics: BrowserAnalytics; beforeEach(() => { - resetAnalyticsInstance(); provider1 = new MockAnalyticsProvider({ debug: false, enabled: true }); provider2 = new MockAnalyticsProvider({ debug: false, enabled: true }); provider3 = new MockAnalyticsProvider({ debug: false, enabled: true }); @@ -403,6 +446,7 @@ describe("Event-Level Routing - Client", () => { it("should only track whitelisted events with 'events' option", async () => { analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -425,6 +469,7 @@ describe("Event-Level Routing - Client", () => { it("should exclude events with 'excludeEvents' option", async () => { analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -448,6 +493,7 @@ describe("Event-Level Routing - Client", () => { it("should match events with 'eventPatterns' glob patterns", async () => { analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -458,7 +504,9 @@ describe("Event-Level Routing - Client", () => { await analytics.initialize(); await analytics.track("newsletter_signup", { email: "test@example.com" }); - await analytics.track("newsletter_unsubscribe", { email: "test@example.com" }); + await analytics.track("newsletter_unsubscribe", { + email: "test@example.com", + }); await analytics.track("user_registered", { userId: "123" }); await analytics.track("page_viewed", { path: "/home" }); await analytics.track("button_clicked", { buttonId: "cta" }); @@ -466,12 +514,15 @@ describe("Event-Level Routing - Client", () => { // Should match patterns expect(provider1.calls.track).toHaveLength(3); expect(provider1.calls.track[0].event.action).toBe("newsletter_signup"); - expect(provider1.calls.track[1].event.action).toBe("newsletter_unsubscribe"); + expect(provider1.calls.track[1].event.action).toBe( + "newsletter_unsubscribe", + ); expect(provider1.calls.track[2].event.action).toBe("user_registered"); }); it("should combine method and event routing", async () => { analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -498,6 +549,7 @@ describe("Event-Level Routing - Client", () => { it("should handle real-world use case: EmitKit for specific events", async () => { analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ // All events go to PostHog provider1, @@ -529,6 +581,7 @@ describe("Event-Level Routing - Client", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -559,7 +612,7 @@ describe("Event-Level Routing - Server", () => { let provider1: MockAnalyticsProvider; let provider2: MockAnalyticsProvider; let provider3: MockAnalyticsProvider; - let analytics: ReturnType; + let analytics: ServerAnalytics; beforeEach(() => { provider1 = new MockAnalyticsProvider({ debug: false, enabled: true }); @@ -578,6 +631,7 @@ describe("Event-Level Routing - Server", () => { it("should only track whitelisted events with 'events' option", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -600,6 +654,7 @@ describe("Event-Level Routing - Server", () => { it("should exclude events with 'excludeEvents' option", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -623,6 +678,7 @@ describe("Event-Level Routing - Server", () => { it("should match events with 'eventPatterns' glob patterns", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -633,7 +689,9 @@ describe("Event-Level Routing - Server", () => { analytics.initialize(); await analytics.track("newsletter_signup", { email: "test@example.com" }); - await analytics.track("newsletter_unsubscribe", { email: "test@example.com" }); + await analytics.track("newsletter_unsubscribe", { + email: "test@example.com", + }); await analytics.track("user_registered", { userId: "123" }); await analytics.track("page_viewed", { path: "/home" }); await analytics.track("button_clicked", { buttonId: "cta" }); @@ -641,12 +699,15 @@ describe("Event-Level Routing - Server", () => { // Should match patterns expect(provider1.calls.track).toHaveLength(3); expect(provider1.calls.track[0].event.action).toBe("newsletter_signup"); - expect(provider1.calls.track[1].event.action).toBe("newsletter_unsubscribe"); + expect(provider1.calls.track[1].event.action).toBe( + "newsletter_unsubscribe", + ); expect(provider1.calls.track[2].event.action).toBe("user_registered"); }); it("should combine method and event routing", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -673,6 +734,7 @@ describe("Event-Level Routing - Server", () => { it("should handle real-world use case: EmitKit for specific events only", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ // All events go to PostHog provider1, @@ -702,6 +764,7 @@ describe("Event-Level Routing - Server", () => { it("should support complex multi-provider event routing", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ // All events provider1, @@ -721,7 +784,9 @@ describe("Event-Level Routing - Server", () => { analytics.initialize(); await analytics.track("newsletter_signup", { email: "test@example.com" }); await analytics.track("user_registered", { userId: "123" }); - await analytics.track("newsletter_unsubscribe", { email: "test@example.com" }); + await analytics.track("newsletter_unsubscribe", { + email: "test@example.com", + }); // Provider1 - all events expect(provider1.calls.track).toHaveLength(3); @@ -729,7 +794,9 @@ describe("Event-Level Routing - Server", () => { // Provider2 - only newsletter events expect(provider2.calls.track).toHaveLength(2); expect(provider2.calls.track[0].event.action).toBe("newsletter_signup"); - expect(provider2.calls.track[1].event.action).toBe("newsletter_unsubscribe"); + expect(provider2.calls.track[1].event.action).toBe( + "newsletter_unsubscribe", + ); // Provider3 - all except newsletter events expect(provider3.calls.track).toHaveLength(1); diff --git a/test/proxy-server.test.ts b/test/proxy-server.test.ts index d936c92..e4da303 100644 --- a/test/proxy-server.test.ts +++ b/test/proxy-server.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { z } from "zod"; import { ingestProxyEvents, createProxyHandler, @@ -6,14 +7,72 @@ import { import { createServerAnalytics } from "@/server.js"; import { MockAnalyticsProvider } from "./mock-provider.js"; import type { ProxyPayload } from "@/providers/proxy/types.js"; +import { defineEvents, noProperties, typed } from "@/core/events/index.js"; +import type { ServerAnalytics } from "@/adapters/server/server-analytics.js"; + +interface UserTraits { + email?: string; + plan?: "free" | "pro"; +} + +const proxyEvents = defineEvents({ + buttonClicked: { + name: "button_clicked", + category: "engagement", + properties: typed<{ buttonId: string }>(), + }, + event1: { + name: "event1", + category: "test", + properties: typed>(), + }, + event2: { + name: "event2", + category: "test", + properties: typed>(), + }, + testEvent: { + name: "test_event", + category: "test", + properties: typed>(), + }, + test: { + name: "test", + category: "test", + properties: typed>(), + }, + sessionStarted: { + name: "session_started", + category: "user", + properties: noProperties(), + }, +}); + +function assertStrictPublicServerTypes( + analytics: ServerAnalytics, +): void { + analytics.identify("user-123", { plan: "pro" }); + analytics.track("button_clicked", { buttonId: "cta" }); + + // @ts-expect-error raw traits do not widen the public identify API + analytics.identify("user-123", { company: "Acme" }); + // @ts-expect-error raw event names do not widen the public track API + analytics.track("unknown_event", {}); + // @ts-expect-error event properties remain strict at the public track API + analytics.track("button_clicked", { label: "CTA" }); +} + +void assertStrictPublicServerTypes; describe("Proxy Server Ingestion", () => { - let serverAnalytics: ReturnType; + let serverAnalytics: ServerAnalytics; let mockProvider: MockAnalyticsProvider; beforeEach(() => { mockProvider = new MockAnalyticsProvider(); serverAnalytics = createServerAnalytics({ + events: proxyEvents, + userTraits: typed(), providers: [mockProvider], }); serverAnalytics.initialize(); @@ -56,6 +115,169 @@ describe("Proxy Server Ingestion", () => { ); }); + it("replays client-shaped propertyless tracks with server options", async () => { + const payload: ProxyPayload = { + events: [ + { + type: "track", + event: { + action: "session_started", + category: "user", + properties: {}, + userId: "user-123", + sessionId: "session-123", + }, + context: { page: { path: "/start" } }, + }, + ], + }; + const request = new Request("http://localhost/api/events", { + method: "POST", + body: JSON.stringify(payload), + }); + + await ingestProxyEvents(request, serverAnalytics); + + expect(mockProvider.calls.track[0]).toMatchObject({ + event: { + action: "session_started", + category: "user", + properties: {}, + userId: "user-123", + sessionId: "session-123", + }, + context: { page: { path: "/start" } }, + }); + }); + + it.each([ + ["non-empty properties", { unexpected: true }], + ["null properties", null], + ["array properties", []], + ["primitive properties", "unexpected"], + ])( + "validates propertyless proxy tracks with %s", + async (_label, properties) => { + const validationError = vi.fn(); + const replayError = vi.fn(); + const strictAnalytics = createServerAnalytics({ + events: proxyEvents, + userTraits: typed(), + providers: [mockProvider], + validation: { onFailure: "throw", onError: validationError }, + }); + const payload = { + events: [ + { + type: "track", + event: { + action: "session_started", + category: "user", + properties, + }, + }, + ], + } as unknown as ProxyPayload; + const request = new Request("http://localhost/api/events", { + method: "POST", + body: JSON.stringify(payload), + }); + + await ingestProxyEvents(request, strictAnalytics, { + onError: replayError, + }); + + expect(validationError).toHaveBeenCalledWith( + expect.objectContaining({ code: "invalid_properties" }), + ); + expect(replayError).toHaveBeenCalledWith( + expect.objectContaining({ code: "invalid_properties" }), + ); + expect(mockProvider.calls.track).toHaveLength(0); + }, + ); + + it("replays propertyless tracks whose payload omits the properties key", async () => { + const payload = { + events: [ + { + type: "track", + event: { + action: "session_started", + category: "user", + userId: "user-123", + sessionId: "session-123", + }, + context: { page: { path: "/start" } }, + }, + ], + } as unknown as ProxyPayload; + const request = new Request("http://localhost/api/events", { + method: "POST", + body: JSON.stringify(payload), + }); + + await ingestProxyEvents(request, serverAnalytics); + + expect(mockProvider.calls.track[0]).toMatchObject({ + event: { + action: "session_started", + category: "user", + properties: {}, + userId: "user-123", + sessionId: "session-123", + }, + context: { page: { path: "/start" } }, + }); + }); + + it("replays transformed schema output without re-validating", async () => { + const transformEvents = defineEvents({ + purchaseCompleted: { + name: "purchase_completed", + category: "revenue", + properties: z.object({ + orderId: z.string(), + amount: z.string().transform(Number), + }), + }, + }); + const provider = new MockAnalyticsProvider(); + const analytics = createServerAnalytics({ + events: transformEvents, + providers: [provider], + validation: { onFailure: "throw" }, + }); + // The client validated { amount: "49" } before the proxy POST, so the + // payload carries the post-transform output { amount: 49 }. + const payload = { + events: [ + { + type: "track", + event: { + action: "purchase_completed", + category: "revenue", + properties: { orderId: "order_1", amount: 49 }, + userId: "user-123", + }, + }, + ], + } as unknown as ProxyPayload; + const request = new Request("http://localhost/api/events", { + method: "POST", + body: JSON.stringify(payload), + }); + + await ingestProxyEvents(request, analytics); + + expect(provider.calls.track).toHaveLength(1); + expect(provider.calls.track[0].event).toMatchObject({ + action: "purchase_completed", + properties: { orderId: "order_1", amount: 49 }, + userId: "user-123", + }); + }); + it("should process identify events", async () => { const payload: ProxyPayload = { events: [ @@ -84,6 +306,95 @@ describe("Proxy Server Ingestion", () => { ); }); + it("replays raw identify traits and enriched typed user context", async () => { + const payload: ProxyPayload = { + events: [ + { + type: "identify", + userId: "user-123", + traits: { email: "user@example.com", plan: "pro" }, + }, + { + type: "track", + event: { + action: "test_event", + category: "ignored-at-replay", + properties: { source: "proxy" }, + }, + context: { + user: { + userId: "user-123", + email: "user@example.com", + traits: { plan: "pro" }, + }, + }, + }, + ], + }; + const request = new Request("http://localhost/api/events", { + method: "POST", + headers: { "X-Forwarded-For": "1.2.3.4" }, + body: JSON.stringify(payload), + }); + + await ingestProxyEvents(request, serverAnalytics, { + enrichContext: () => ({ server: { requestId: "req-123" } }), + }); + + expect(mockProvider.calls.identify[0]).toEqual({ + userId: "user-123", + traits: { email: "user@example.com", plan: "pro" }, + }); + expect(mockProvider.calls.track[0].context).toMatchObject({ + user: { + userId: "user-123", + email: "user@example.com", + traits: { plan: "pro" }, + }, + server: { requestId: "req-123" }, + device: { ip: "1.2.3.4" }, + }); + }); + + it("routes unknown raw track names through server validation policy", async () => { + const validationError = vi.fn(); + const replayError = vi.fn(); + const strictAnalytics = createServerAnalytics({ + events: proxyEvents, + userTraits: typed(), + providers: [mockProvider], + validation: { onFailure: "throw", onError: validationError }, + }); + const payload: ProxyPayload = { + events: [ + { + type: "track", + event: { + action: "not_registered", + category: "raw", + properties: { secret: true }, + }, + }, + ], + }; + const request = new Request("http://localhost/api/events", { + method: "POST", + body: JSON.stringify(payload), + }); + + await ingestProxyEvents(request, strictAnalytics, { + onError: replayError, + }); + + expect(validationError).toHaveBeenCalledWith( + expect.objectContaining({ code: "unknown_event" }), + ); + expect(replayError).toHaveBeenCalledWith( + expect.objectContaining({ code: "unknown_event" }), + ); + expect(mockProvider.calls.track).toHaveLength(0); + }); + it("should process pageView events", async () => { const payload: ProxyPayload = { events: [ @@ -465,6 +776,8 @@ describe("Proxy Server Ingestion", () => { }; const analytics = createServerAnalytics({ + events: proxyEvents, + userTraits: typed(), providers: [errorProvider], }); analytics.initialize(); diff --git a/test/server-analytics.test.ts b/test/server-analytics.test.ts index 90de7da..8931b26 100644 --- a/test/server-analytics.test.ts +++ b/test/server-analytics.test.ts @@ -1,287 +1,441 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { createServerAnalytics, type ServerAnalytics } from "@/server"; +import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; +import { z } from "zod"; +import type { ServerAnalytics } from "@/adapters/server/server-analytics"; +import { + type AnalyticsValidationError, + defineEvents, + noProperties, + typed, +} from "@/core/events"; +import { createServerAnalytics } from "@/server"; import { MockAnalyticsProvider } from "./mock-provider"; -import type { CreateEventDefinition, EventCollection } from "@/core/events"; -// Define test events -const TestEvents = { +interface UserTraits { + email?: string; + name?: string; + plan?: "free" | "pro"; +} + +const events = defineEvents({ userSignedUp: { name: "user_signed_up", category: "user", - properties: {} as { + properties: typed<{ userId: string; email: string; plan: "free" | "pro"; - }, + }>(), }, featureUsed: { name: "feature_used", category: "engagement", - properties: {} as { - featureName: string; - userId: string; - }, + properties: typed<{ featureName: string; userId: string }>(), + }, + testEvent: { + name: "test_event", + category: "custom-category", + properties: typed<{ action?: string; data?: string }>(), }, -} as const satisfies EventCollection< - Record> ->; + sessionStarted: { + name: "session_started", + category: "user", + properties: noProperties(), + }, + normalized: { + name: "normalized_event", + category: "conversion", + properties: z.object({ label: z.string() }).transform(({ label }) => ({ + normalizedLabel: label.trim().toLowerCase(), + })), + }, +}); + +type TestAnalytics = ServerAnalytics; + +function assertServerTypes(): void { + const analytics = createServerAnalytics({ + events, + userTraits: typed(), + }); + + expectTypeOf(analytics).toEqualTypeOf(); + expectTypeOf(analytics.identify) + .parameter(1) + .toEqualTypeOf(); + + analytics.track("user_signed_up", { + userId: "user_123", + email: "user@example.com", + plan: "pro", + }); + analytics.track("session_started"); + analytics.track("session_started", { userId: "user_123" }); + analytics.identify("user_123", { plan: "pro" }); + + // @ts-expect-error unknown event names are rejected + analytics.track("unknown_event", {}); + // @ts-expect-error properties are required for property-bearing events + analytics.track("user_signed_up"); + // @ts-expect-error missing required event property + analytics.track("user_signed_up", { userId: "user_123" }); + // @ts-expect-error extra event properties are rejected + analytics.track("test_event", { action: "clicked", extra: true }); + // @ts-expect-error no undefined properties placeholder + analytics.track("session_started", undefined); + // @ts-expect-error properties are forbidden + analytics.track("session_started", { unexpected: true }); + // @ts-expect-error inferred user traits reject unknown properties + analytics.identify("user_123", { company: "Acme" }); +} + +void assertServerTypes; describe("Server Analytics", () => { let mockProvider: MockAnalyticsProvider; - let analytics: ReturnType; + let analytics: TestAnalytics; beforeEach(() => { mockProvider = new MockAnalyticsProvider({ debug: false, enabled: true }); analytics = createServerAnalytics({ + events, + userTraits: typed(), providers: [mockProvider], + validation: { onFailure: "throw" }, debug: false, enabled: true, }); }); - it("should initialize providers", () => { - expect(mockProvider.calls.initialize).toBe(1); - }); - - it("should track events with correct properties", async () => { - await analytics.track(TestEvents.userSignedUp.name, { - userId: "user-123", - email: "test@example.com", - plan: "pro", + it("returns a fresh initialized instance from each factory call", () => { + const firstProvider = new MockAnalyticsProvider({ enabled: true }); + const secondProvider = new MockAnalyticsProvider({ enabled: true }); + const first = createServerAnalytics({ events, providers: [firstProvider] }); + const second = createServerAnalytics({ + events, + providers: [secondProvider], }); - expect(mockProvider.calls.track).toHaveLength(1); - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.event.action).toBe("user_signed_up"); - expect(trackedEvent.event.category).toBe("user"); - expect(trackedEvent.event.properties).toEqual({ - userId: "user-123", - email: "test@example.com", - plan: "pro", - }); + expect(first).not.toBe(second); + expect(firstProvider.calls.initialize).toBe(1); + expect(secondProvider.calls.initialize).toBe(1); }); - it("should track events with user context", async () => { + it("tracks definition categories, properties, and server options", async () => { await analytics.track( - TestEvents.featureUsed.name, + "feature_used", + { featureName: "export", userId: "user-123" }, { - featureName: "export", userId: "user-123", + sessionId: "session-456", + context: { page: { path: "/api/export" } }, }, - { + ); + + expect(mockProvider.calls.track[0]).toMatchObject({ + event: { + action: "feature_used", + category: "engagement", + properties: { featureName: "export", userId: "user-123" }, userId: "user-123", sessionId: "session-456", - context: { - page: { - path: "/api/export", - }, - }, }, - ); + context: { page: { path: "/api/export" } }, + }); + }); - expect(mockProvider.calls.track).toHaveLength(1); - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.event.userId).toBe("user-123"); - expect(trackedEvent.event.sessionId).toBe("session-456"); - expect(trackedEvent.context?.page?.path).toBe("/api/export"); + it("uses the exact definition category instead of deriving one", async () => { + await analytics.track("test_event", { data: "test" }); + + expect(mockProvider.calls.track[0].event.category).toBe("custom-category"); }); - it("should accept user data in track options via user field", async () => { + it("accepts inferred user traits for identify and event context", async () => { + await analytics.identify("user-123", { + email: "test@example.com", + name: "Test User", + plan: "pro", + }); await analytics.track( "test_event", + { action: "clicked" }, { - action: "clicked", - }, - { - userId: "user-123", user: { userId: "user-123", email: "test@example.com", - traits: { - plan: "pro", - name: "Test User", - }, + traits: { plan: "pro" }, }, }, ); - expect(mockProvider.calls.track).toHaveLength(1); - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.context?.user).toEqual({ + expect(mockProvider.calls.identify[0]).toEqual({ userId: "user-123", - email: "test@example.com", traits: { - plan: "pro", + email: "test@example.com", name: "Test User", + plan: "pro", }, }); - }); - - it("should accept user data via context.user field", async () => { - await analytics.track( - "test_event", - { - action: "clicked", - }, - { - userId: "user-123", - context: { - user: { - userId: "user-123", - email: "user@example.com", - }, - page: { - path: "/dashboard", - }, - }, - }, - ); - - expect(mockProvider.calls.track).toHaveLength(1); - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.context?.user).toEqual({ + expect(mockProvider.calls.track[0].context?.user).toEqual({ userId: "user-123", - email: "user@example.com", + email: "test@example.com", + traits: { plan: "pro" }, }); - expect(trackedEvent.context?.page?.path).toBe("/dashboard"); }); - it("should prioritize user field over context.user", async () => { - await analytics.track( - "test_event", - { - action: "clicked", - }, - { - userId: "user-123", + it("preserves default user context when a track call does not override it", async () => { + const withDefaultContext = createServerAnalytics({ + events, + userTraits: typed(), + providers: [mockProvider], + defaultContext: { user: { - email: "priority@example.com", - }, - context: { - user: { - email: "fallback@example.com", - }, + userId: "default-user", + email: "default@example.com", + traits: { plan: "free" }, }, }, - ); + }); - expect(mockProvider.calls.track).toHaveLength(1); - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.context?.user?.email).toBe("priority@example.com"); + await withDefaultContext.track("test_event", {}); + + expect(mockProvider.calls.track[0].context?.user).toEqual({ + userId: "default-user", + email: "default@example.com", + traits: { plan: "free" }, + }); }); - it("should identify users", () => { - analytics.identify("user-123", { - email: "test@example.com", - name: "Test User", - plan: "pro", + it("accepts propertyless calls with no options or options in argument two", async () => { + await analytics.track("session_started"); + await analytics.track("session_started", { + userId: "user_123", + sessionId: "session_123", }); - expect(mockProvider.calls.identify).toHaveLength(1); - expect(mockProvider.calls.identify[0]).toEqual({ - userId: "user-123", - traits: { - email: "test@example.com", - name: "Test User", - plan: "pro", - }, + expect(mockProvider.calls.track).toHaveLength(2); + expect(mockProvider.calls.track[0].event.properties).toEqual({}); + expect(mockProvider.calls.track[1].event).toMatchObject({ + properties: {}, + userId: "user_123", + sessionId: "session_123", }); }); - it("should track page views", () => { - analytics.pageView( - { - path: "/dashboard", - title: "Dashboard", - }, - { - context: { - device: { - type: "desktop", - os: "macOS", - }, - }, - }, + it.each([ + ["explicit undefined", undefined], + ["null", null], + ["an array", []], + ["a primitive", "user_123"], + ["unknown option keys", { unexpected: true }], + ])( + "routes propertyless %s through invalid_properties", + async (_label, value) => { + const runtimeAnalytics = analytics as unknown as { + track(name: string, options: unknown): Promise; + }; + + await expect( + runtimeAnalytics.track("session_started", value), + ).rejects.toMatchObject({ code: "invalid_properties" }); + expect(mockProvider.calls.track).toHaveLength(0); + }, + ); + + it.each([ + ["null", null], + ["an array", []], + ["a primitive", "user_123"], + ["unknown option keys", { unexpected: true }], + ])( + "throws invalid_options for property-bearing %s options", + async (_label, value) => { + const onError = vi.fn(); + const strict = createServerAnalytics({ + events, + providers: [mockProvider], + validation: { onFailure: "throw", onError }, + }); + const runtimeAnalytics = strict as unknown as { + track( + name: string, + properties: unknown, + options: unknown, + ): Promise; + }; + + await expect( + runtimeAnalytics.track("test_event", {}, value), + ).rejects.toMatchObject({ code: "invalid_options" }); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "invalid_options" }), + ); + expect(mockProvider.calls.track).toHaveLength(0); + }, + ); + + it("drops invalid property-bearing options through the shared policy", async () => { + const onError = vi.fn(); + const dropping = createServerAnalytics({ + events, + providers: [mockProvider], + validation: { onError }, + }); + const runtimeAnalytics = dropping as unknown as { + track(name: string, properties: unknown, options: unknown): Promise; + }; + + await expect( + runtimeAnalytics.track("test_event", {}, { unexpected: true }), + ).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "invalid_options" }), ); + expect(mockProvider.calls.track).toHaveLength(0); + }); + + it("accepts propertyless calls passing undefined properties with options", async () => { + const runtimeAnalytics = analytics as unknown as { + track( + name: string, + properties: unknown, + options: unknown, + ): Promise; + }; - expect(mockProvider.calls.pageView).toHaveLength(1); - const pageView = mockProvider.calls.pageView[0]; - expect(pageView.properties).toEqual({ - path: "/dashboard", - title: "Dashboard", + await runtimeAnalytics.track("session_started", undefined, { + userId: "user_123", + sessionId: "session_123", }); - expect(pageView.context?.device).toEqual({ - type: "desktop", - os: "macOS", + + expect(mockProvider.calls.track).toHaveLength(1); + expect(mockProvider.calls.track[0].event).toMatchObject({ + properties: {}, + userId: "user_123", + sessionId: "session_123", }); }); - it("should handle multiple providers", async () => { - const mockProvider2 = new MockAnalyticsProvider({ enabled: true }); - const multiAnalytics = createServerAnalytics<{ - userSignedUp: { - name: "user_signed_up"; - category: "user"; - properties: { userId: string }; - }; - }>({ - providers: [mockProvider, mockProvider2], - enabled: true, + it("delivers transformed schema output to every routed provider", async () => { + const secondProvider = new MockAnalyticsProvider({ enabled: true }); + const transformed = createServerAnalytics({ + events, + providers: [mockProvider, secondProvider], + validation: { onFailure: "throw" }, }); - await multiAnalytics.track("user_signed_up", { userId: "user-123" }); + await transformed.track("normalized_event", { label: " SIGN UP " }); - expect(mockProvider.calls.track).toHaveLength(1); - expect(mockProvider2.calls.track).toHaveLength(1); + expect(mockProvider.calls.track[0].event.properties).toEqual({ + normalizedLabel: "sign up", + }); + expect(secondProvider.calls.track[0].event.properties).toEqual({ + normalizedLabel: "sign up", + }); }); - it("should respect enabled flag", () => { - const disabledProvider = new MockAnalyticsProvider({ enabled: false }); - const disabledAnalytics = createServerAnalytics<{ - userSignedUp: { - name: "user_signed_up"; - category: "user"; - properties: { userId: string }; - }; - }>({ - providers: [disabledProvider], - enabled: true, + it("drops invalid and unknown events by default and reports failures", async () => { + const onError = vi.fn<(error: AnalyticsValidationError) => void>(); + const dropping = createServerAnalytics({ + events, + providers: [mockProvider], + validation: { onError }, }); + const runtimeAnalytics = dropping as unknown as { + track(name: string, properties?: unknown): Promise; + }; - disabledAnalytics.track("user_signed_up", { userId: "user-123" }); + await runtimeAnalytics.track("normalized_event", { label: 42 }); + await runtimeAnalytics.track("not_registered", { secret: true }); - expect(disabledProvider.calls.track).toHaveLength(0); + expect(mockProvider.calls.track).toHaveLength(0); + expect(onError.mock.calls.map(([error]) => error.code)).toEqual([ + "invalid_properties", + "unknown_event", + ]); }); - it("should extract category from event name", async () => { - await analytics.track("custom_action", { data: "test" }); + it("throws validation failures when configured", async () => { + const runtimeAnalytics = analytics as unknown as { + track(name: string, properties?: unknown): Promise; + }; - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.event.category).toBe("custom"); + await expect( + runtimeAnalytics.track("normalized_event", { label: 42 }), + ).rejects.toMatchObject({ code: "invalid_properties" }); + await expect( + runtimeAnalytics.track("not_registered", {}), + ).rejects.toMatchObject({ code: "unknown_event" }); + expect(mockProvider.calls.track).toHaveLength(0); }); - it("should use default category for events without underscore", async () => { - await analytics.track("singleword", { data: "test" }); + it("short-circuits disabled instances before initialization and validation", async () => { + const provider = new MockAnalyticsProvider({ enabled: true }); + const onError = vi.fn(); + const disabled = createServerAnalytics({ + events, + providers: [provider], + validation: { onFailure: "throw", onError }, + enabled: false, + }); + const runtimeAnalytics = disabled as unknown as { + track(name: string, properties?: unknown): Promise; + }; + + await expect( + runtimeAnalytics.track("not_registered", { secret: true }), + ).resolves.toBeUndefined(); + expect(provider.calls.initialize).toBe(0); + expect(provider.calls.track).toHaveLength(0); + expect(onError).not.toHaveBeenCalled(); + }); + + it("isolates provider tracking failures", async () => { + const failing = new MockAnalyticsProvider({ enabled: true }); + failing.name = "Failing"; + failing.track = () => { + throw new Error("provider failed"); + }; + const succeeding = new MockAnalyticsProvider({ enabled: true }); + const isolated = createServerAnalytics({ + events, + providers: [failing, succeeding], + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(isolated.track("test_event", {})).resolves.toBeUndefined(); + expect(succeeding.calls.track).toHaveLength(1); + expect(errorSpy).toHaveBeenCalledWith( + "[Analytics] Provider Failing failed to track event:", + expect.any(Error), + ); + errorSpy.mockRestore(); + }); + + it("tracks page views with context", async () => { + await analytics.pageView( + { path: "/dashboard", title: "Dashboard" }, + { context: { device: { type: "desktop", os: "macOS" } } }, + ); - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.event.category).toBe("engagement"); + expect(mockProvider.calls.pageView[0]).toEqual({ + properties: { path: "/dashboard", title: "Dashboard" }, + context: { device: { type: "desktop", os: "macOS" } }, + }); }); - it("should handle shutdown gracefully", async () => { - // Add a shutdown method to mock provider + it("shuts down providers that support it", async () => { const shutdownProvider = new MockAnalyticsProvider({ enabled: true, }) as MockAnalyticsProvider & { shutdown: () => Promise }; - let shutdownCalled = false; - shutdownProvider.shutdown = async () => { - shutdownCalled = true; - }; - - const analyticsWithShutdown = createServerAnalytics({ + const shutdown = vi.fn(async () => {}); + shutdownProvider.shutdown = shutdown; + const withShutdown = createServerAnalytics({ + events, providers: [shutdownProvider], }); - await analyticsWithShutdown.shutdown(); - expect(shutdownCalled).toBe(true); + await withShutdown.shutdown(); + + expect(shutdown).toHaveBeenCalledOnce(); }); }); diff --git a/test/server.test.ts b/test/server.test.ts index 36774d1..13de376 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1,9 +1,54 @@ -import { describe, it, expect } from "vitest"; -import * as ServerAnalytics from "@/server"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { ServerAnalytics as ServerAnalyticsClass } from "@/adapters/server/server-analytics"; +import * as ServerAnalytics from "@/server/index"; +// @ts-expect-error applyValidationFailurePolicy is internal +import { applyValidationFailurePolicy } from "@/server/index"; + +void applyValidationFailurePolicy; + +const { defineEvents, noProperties, typed } = ServerAnalytics; + +interface UserTraits { + plan: "free" | "pro"; +} + +const events = defineEvents({ + sessionStarted: { + name: "session_started", + category: "user", + properties: noProperties(), + }, +}); + +function assertServerFactoryTypes(): void { + const analytics = ServerAnalytics.createServerAnalytics({ + events, + userTraits: typed(), + }); + + expectTypeOf(analytics).toEqualTypeOf< + ServerAnalyticsClass + >(); + + // @ts-expect-error events is required + ServerAnalytics.createServerAnalytics({}); +} + +void assertServerFactoryTypes; describe("trakoo/server exports", () => { it("should export server analytics functions", () => { expect(ServerAnalytics.createServerAnalytics).toBeDefined(); expect(ServerAnalytics.ServerAnalytics).toBeDefined(); }); + + it("exports the registry helpers and public validation error", () => { + expect(ServerAnalytics.defineEvents).toBeDefined(); + expect(ServerAnalytics.typed).toBeDefined(); + expect(ServerAnalytics.noProperties).toBeDefined(); + expect(ServerAnalytics.AnalyticsValidationError).toBeDefined(); + expect(ServerAnalytics).not.toHaveProperty( + "applyValidationFailurePolicy", + ); + }); }); diff --git a/test/standard-schema-integration.test.ts b/test/standard-schema-integration.test.ts new file mode 100644 index 0000000..d4a4ab3 --- /dev/null +++ b/test/standard-schema-integration.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { z } from "zod"; +import { + defineEvents, + type EventInputMap, + type EventOutputMap, +} from "@/index"; +import { createServerAnalytics } from "@/server/index"; +import { MockAnalyticsProvider } from "./mock-provider"; + +const events = defineEvents({ + purchaseCompleted: { + name: "purchase_completed", + category: "conversion", + properties: z.object({ + orderId: z.string(), + amount: z.string().transform(Number), + }), + }, +}); + +describe("Standard Schema integration", () => { + it("infers Zod input and output types directly", () => { + expectTypeOf< + EventInputMap["purchase_completed"] + >().toEqualTypeOf<{ orderId: string; amount: string }>(); + expectTypeOf< + EventOutputMap["purchase_completed"] + >().toEqualTypeOf<{ orderId: string; amount: number }>(); + }); + + it("delivers transformed Zod output to the provider", async () => { + const provider = new MockAnalyticsProvider({ enabled: true }); + const analytics = createServerAnalytics({ + events, + providers: [provider], + validation: { onFailure: "throw" }, + }); + + await analytics.track("purchase_completed", { + orderId: "order_1", + amount: "49", + }); + + expect(provider.calls.track[0].event.properties).toEqual({ + orderId: "order_1", + amount: 49, + }); + }); +}); diff --git a/test/type-diagnostics.test.ts b/test/type-diagnostics.test.ts new file mode 100644 index 0000000..de74a3b --- /dev/null +++ b/test/type-diagnostics.test.ts @@ -0,0 +1,68 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +describe("public event type diagnostics", () => { + it("reports concise public names for invalid event usage", () => { + const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const fixture = resolve( + root, + "test/fixtures/invalid-event-usage.ts", + ); + const configDirectory = mkdtempSync( + join(tmpdir(), "trakoo-type-diagnostics-"), + ); + const configPath = join(configDirectory, "tsconfig.json"); + const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + const result = (() => { + try { + writeFileSync( + configPath, + JSON.stringify({ + extends: resolve(root, "tsconfig.json"), + compilerOptions: { + strict: true, + noEmit: true, + types: ["node"], + typeRoots: [resolve(root, "node_modules/@types")], + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + }, + files: [fixture], + include: [], + exclude: [], + }), + ); + return spawnSync( + pnpm, + [ + "exec", + "tsc", + "--pretty", + "false", + "--project", + configPath, + ], + { cwd: root, encoding: "utf8" }, + ); + } finally { + rmSync(configDirectory, { recursive: true, force: true }); + } + })(); + const output = `${result.stdout}${result.stderr}`; + const diagnosticLines = output.trim().split(/\r?\n/); + + expect(result.status).not.toBe(0); + expect(output).toContain("purchase_compeleted"); + expect(output).toContain("purchase_completed"); + expect(output).toContain("orderId"); + expect(output).toContain("amount"); + expect(diagnosticLines.length).toBeLessThan(30); + expect(output).not.toContain("PropertiesForName"); + expect(output).not.toContain("DefinitionForName"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 443d0da..b14c331 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "bundler", "noImplicitReturns": true, + "strictNullChecks": true, "esModuleInterop": true, "outDir": "dist", "sourceMap": true, @@ -19,5 +20,10 @@ }, "compileOnSave": true, "include": ["src", "test"], - "exclude": ["node_modules", "dist", "coverage"] + "exclude": [ + "node_modules", + "dist", + "coverage", + "test/fixtures/invalid-event-usage.ts" + ] } diff --git a/www/content/docs/(Getting Started)/index.mdx b/www/content/docs/(Getting Started)/index.mdx index 079e008..8833e1e 100644 --- a/www/content/docs/(Getting Started)/index.mdx +++ b/www/content/docs/(Getting Started)/index.mdx @@ -9,7 +9,7 @@ sidebar: trakoo gives your TypeScript app a single, typed analytics API. You define the events your product emits, pick the providers you trust, and call `track()`. When your analytics stack changes, those call sites stay the same — you swap providers in one place, not across your codebase. -It runs the same way in the browser and on the server, ships zero dependencies, and works on the edge. +It runs in browsers, servers, and edge runtimes. The two environment-specific factories share the same event registry. ## Providers @@ -41,23 +41,21 @@ Start with one provider and add more when you need them. Every provider is optio You describe your events once. That single definition becomes autocomplete, compile-time validation, and provider routing everywhere you track. ```typescript title="lib/events.ts" -import type { CreateEventDefinition, EventCollection } from 'trakoo'; +import { defineEvents, typed } from 'trakoo'; -export const appEvents = { +export const appEvents = defineEvents({ userSignedUp: { name: 'user_signed_up', category: 'user', - properties: {} as { + properties: typed<{ email: string; plan: 'free' | 'pro' | 'enterprise'; - } + }>() } -} as const satisfies EventCollection>>; - -export type AppEvents = typeof appEvents; +}); ``` -Create an analytics instance with the providers you want, typed by those events: +Create an application-owned analytics instance with the providers you want. Passing the registry value infers every event type: ```typescript title="lib/analytics.ts" import { createClientAnalytics } from 'trakoo/client'; @@ -65,9 +63,10 @@ import { PostHogClientProvider, VisitorsClientProvider } from 'trakoo/providers/client'; -import type { AppEvents } from './events'; +import { appEvents } from './events'; -export const analytics = createClientAnalytics({ +export const analytics = createClientAnalytics({ + events: appEvents, providers: [ new PostHogClientProvider({ token: import.meta.env.VITE_POSTHOG_KEY }), new VisitorsClientProvider({ token: import.meta.env.VITE_VISITORS_TOKEN }) @@ -75,10 +74,12 @@ export const analytics = createClientAnalytics({ }); ``` +The root `trakoo` import contains shared types and environment-neutral event helpers. Import factories and providers from their client or server subpaths. + Then track from anywhere. TypeScript knows the valid event names and their required properties: ```typescript -analytics.track('user_signed_up', { +await analytics.track('user_signed_up', { email: 'ada@example.com', plan: 'pro' }); diff --git a/www/content/docs/(Getting Started)/installation.mdx b/www/content/docs/(Getting Started)/installation.mdx index 0ff787c..03bd290 100644 --- a/www/content/docs/(Getting Started)/installation.mdx +++ b/www/content/docs/(Getting Started)/installation.mdx @@ -41,6 +41,7 @@ bun add trakoo trakoo’s public API is split by environment: ```typescript +import { defineEvents, noProperties, typed } from 'trakoo'; import { createClientAnalytics } from 'trakoo/client'; import { createServerAnalytics } from 'trakoo/server'; @@ -48,7 +49,7 @@ import { PostHogClientProvider } from 'trakoo/providers/client'; import { PostHogServerProvider } from 'trakoo/providers/server'; ``` -Use `trakoo/providers/client` in browser bundles and `trakoo/providers/server` in server-only code. +The root `trakoo` entry point contains environment-neutral event helpers and shared types. Factories live on `trakoo/client` and `trakoo/server`. Use `trakoo/providers/client` in browser bundles and `trakoo/providers/server` in server-only code; there is no combined provider entry point. ## Provider SDKs @@ -178,10 +179,20 @@ PIRSCH_ACCESS_KEY=pa_xxx Create a small file and let TypeScript confirm the package resolves: ```typescript +import { defineEvents, noProperties } from 'trakoo'; import { createClientAnalytics } from 'trakoo/client'; import { PostHogClientProvider } from 'trakoo/providers/client'; +const events = defineEvents({ + testEvent: { + name: 'test_event', + category: 'engagement', + properties: noProperties() + } +}); + const analytics = createClientAnalytics({ + events, providers: [ new PostHogClientProvider({ token: 'test' @@ -189,7 +200,7 @@ const analytics = createClientAnalytics({ ] }); -analytics.track('test_event', {}); +await analytics.track('test_event'); ``` If your project compiles, continue to the [Quick Start](/docs/quick-start). diff --git a/www/content/docs/(Getting Started)/quick-start.mdx b/www/content/docs/(Getting Started)/quick-start.mdx index bd13c2a..bed1895 100644 --- a/www/content/docs/(Getting Started)/quick-start.mdx +++ b/www/content/docs/(Getting Started)/quick-start.mdx @@ -1,9 +1,9 @@ --- title: Quick Start -description: Install trakoo, define typed events, see them locally, then send them to a real provider — client and server. +description: Install trakoo, define a typed event registry, and send events from client and server instances. --- -This guide takes you from an empty project to typed events flowing to a real analytics provider. You'll start with a local console provider so you can see the event object immediately — no vendor account required — then swap in PostHog and add server-side tracking. The event definitions never change along the way. +This guide starts with a local console provider, then switches to PostHog and adds server tracking. The same registry drives every instance. ## 1. Install @@ -11,41 +11,44 @@ This guide takes you from an empty project to typed events flowing to a real ana pnpm install trakoo ``` -Use npm, yarn, or bun if that's what your project uses. Provider SDKs come later, per provider — see [Installation](/docs/installation) for the full matrix. +See [Installation](/docs/installation) for npm, yarn, bun, and the provider SDK matrix. ## 2. Define your events -Describe every event your app emits in one place. The object keys organize your code; the `name` values are what `track()` sends to providers. +The object keys organize your source. The `name` values are the wire names accepted by `track()` and sent to providers. ```typescript title="lib/events.ts" -import type { CreateEventDefinition, EventCollection } from 'trakoo'; +import { defineEvents, noProperties, typed } from 'trakoo'; -export const appEvents = { +export const appEvents = defineEvents({ buttonClicked: { name: 'button_clicked', category: 'engagement', - properties: {} as { + properties: typed<{ buttonId: string; location: 'hero' | 'nav' | 'pricing'; - } + }>() }, userSignedUp: { name: 'user_signed_up', category: 'user', - properties: {} as { + properties: typed<{ email: string; plan: 'free' | 'pro' | 'enterprise'; referralSource?: string; - } + }>() + }, + sessionStarted: { + name: 'session_started', + category: 'user', + properties: noProperties() } -} as const satisfies EventCollection>>; - -export type AppEvents = typeof appEvents; +}); ``` -## 3. See it work locally +`typed()` is the primary, validator-free API. `noProperties()` makes the properties argument illegal, so the last event is tracked as `analytics.track('session_started')`. -Before wiring a vendor, point trakoo at a small console provider. You'll see the exact event object in your terminal or browser console — a fast way to confirm your setup and understand the shape of a tracked event. +## 3. See it work locally ```typescript title="lib/analytics.ts" import { @@ -54,7 +57,7 @@ import { type BaseEvent, type EventContext } from 'trakoo/client'; -import type { AppEvents } from './events'; +import { appEvents } from './events'; class ConsoleProvider extends BaseAnalyticsProvider { name = 'ConsoleProvider'; @@ -70,25 +73,24 @@ class ConsoleProvider extends BaseAnalyticsProvider { } pageView(properties?: Record) { - console.log('page view', { properties }); + console.log('page view', properties); } - reset() { - console.log('reset'); - } + reset() {} } -export const analytics = createClientAnalytics({ +export const analytics = createClientAnalytics({ + events: appEvents, providers: [new ConsoleProvider({ debug: true })], debug: import.meta.env.DEV }); ``` -`createClientAnalytics()` initializes in the background. Your app can call `analytics.track()` right away. +Each factory call creates a fresh instance. This module owns the instance; components import it from your module rather than relying on a trakoo singleton or global helper. Client initialization begins in the background, and `track()` waits for it when necessary. ## 4. Track from your UI -Call `track()` wherever the action happens. If you mistype `button_clickd` or forget `location`, TypeScript flags it before the event ships. +If you mistype the wire name or omit `location`, TypeScript flags the call. @@ -100,10 +102,14 @@ export function SignupButton() { return (