From 21f9449fe8d089f41329e328e929abf7016dd94f Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 13:53:30 +0200 Subject: [PATCH 01/20] docs: design Standard Schema event definitions --- ...tandard-schema-event-definitions-design.md | 426 ++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-standard-schema-event-definitions-design.md 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..3d4c437 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-standard-schema-event-definitions-design.md @@ -0,0 +1,426 @@ +# 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()` and `typed()`. 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. `T` must be an object property shape. + +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. + +### 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; +- 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. + +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 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 type-only definition uses the original input as its output. +7. Apply existing provider method and event routing. +8. Send the same normalized output to every selected provider. + +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. + +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. + +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, +}); +``` + +The obsolete `CreateEventDefinition`, `EventCollection`, extraction helpers, +and generic-only factory signatures should be removed rather than maintaining +two competing definition systems. Shared low-level event/provider 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. +- 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. + +### 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`. +- 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. From 72458ac500faa568d55d2349f7a6cdddc439f02a Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 14:14:23 +0200 Subject: [PATCH 02/20] docs: address Standard Schema design review --- ...tandard-schema-event-definitions-design.md | 96 ++++++++++++++++--- 1 file changed, 82 insertions(+), 14 deletions(-) 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 index 3d4c437..7caa6ff 100644 --- 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 @@ -32,9 +32,10 @@ work. ## Public Event API The root `trakoo` entry point will export the environment-neutral runtime -helpers `defineEvents()` and `typed()`. 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. +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 @@ -86,12 +87,41 @@ export const appEvents = defineEvents({ `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. `T` must be an object property shape. +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 event a one-argument `track()` call. Supplying a second argument +is a compile-time error; JavaScript or type-bypassing callers that supply one +receive `invalid_properties` under the configured validation-failure policy. +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. @@ -102,7 +132,8 @@ providers. - 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; +- 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 @@ -139,6 +170,20 @@ 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: @@ -155,10 +200,11 @@ const analytics = createClientAnalytics({ }); ``` -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. +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: @@ -186,10 +232,15 @@ Every `track()` call follows the same core sequence on client and server: 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 type-only definition uses the original input as its output. + 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 @@ -287,6 +338,11 @@ validate their property values. 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 @@ -312,6 +368,9 @@ 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 second `track()` argument; +they do not require an empty generic or empty object literal. + 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 @@ -355,10 +414,13 @@ const analytics = createClientAnalytics({ }); ``` -The obsolete `CreateEventDefinition`, `EventCollection`, extraction helpers, -and generic-only factory signatures should be removed rather than maintaining -two competing definition systems. Shared low-level event/provider types that -remain useful are retained. +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 @@ -374,6 +436,8 @@ failure policy. - 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 `track()` call and reject a + supplied properties argument. - Unknown names and incorrect properties fail typechecking on both client and server. - Factory calls require no event generic. @@ -381,6 +445,8 @@ failure policy. 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 @@ -396,6 +462,8 @@ failure policy. - 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. From 8a625603838d01bf57f33c2152ae715801b714c4 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 14:41:20 +0200 Subject: [PATCH 03/20] docs: plan Standard Schema event definitions --- ...07-22-standard-schema-event-definitions.md | 983 ++++++++++++++++++ 1 file changed, 983 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-standard-schema-event-definitions.md 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..11df369 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-standard-schema-event-definitions.md @@ -0,0 +1,983 @@ +# 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(); + +// @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 +type ClientTrackArgs = EventInputMap[N] extends undefined + ? [eventName: N] + : [eventName: N, properties: EventInputMap[N]]; + +type ServerTrackArgs = EventInputMap[N] extends undefined + ? [eventName: N] | [eventName: N, properties: undefined, 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. + +- [ ] **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 { + readonly name: string; + readonly category: EventCategory; + readonly properties: Record; +} + +export async function resolveEvent( + registry: EventRegistry, + eventName: string, + input: unknown, + validation: ValidationConfig | undefined, + debug: boolean, +): Promise; +``` + +Lookup, 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. + +- [ ] **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; call `resolveEvent`; return on drop; build +`BaseEvent` from resolved name/category/properties. Remove category derivation. +Preserve browser/session context and provider routing. + +- [ ] **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", undefined, { userId: "user_123" }); +// @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. + +- [ ] **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. Widen ordinary-interface output only where the +shared provider transport requires `Record`. For propertyless +events, only omitted or `undefined` properties are valid; options occupy +argument three. + +- [ ] **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. + +- [ ] **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: `scripts/verify-package.mjs` +- Modify: `package.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. + +- [ ] **Step 2: Confirm stale public API failure** + +```bash +pnpm vitest run test/standard-schema-integration.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 +AnyEventName +AnyEventProperties +``` + +Retain transport/provider types. 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 { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const contextDirectory = join(root, ".context"); +mkdirSync(contextDirectory, { recursive: true }); +const consumerDirectory = mkdtempSync( + join(contextDirectory, "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"); + } +} finally { + if (tarballPath) rmSync(tarballPath, { force: true }); + rmSync(consumerDirectory, { recursive: true, force: true }); +} +``` + +Do not install Zod in this fixture; it verifies the validator-free package path. + +- [ ] **Step 5: Verify and commit** + +```bash +pnpm test +pnpm typecheck +pnpm lint +pnpm build +pnpm verify:package +git add package.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 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. +``` + +- [ ] **Step 5: Run stale API checks** + +```bash +rg -n 'CreateEventDefinition|EventCollection|ExtractEventNames|ExtractEventPropertiesFromCollection|EventMapFromCollection|as const satisfies|properties: \{\} as|create(Client|Server)Analytics<' src test readme.md www/content +rg -n 'getAnalytics\(|resetAnalyticsInstance|createAnalytics as|createClientAnalytics as createAnalytics' src test readme.md www/content +``` + +Expected: no output and exit code 1 from both commands. Historical design/plan +documents are intentionally outside the scan. + +- [ ] **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. From 3877dd5115e55d8f0a6ff310428f1d5c3c3b3a1a Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 14:56:00 +0200 Subject: [PATCH 04/20] docs: address Standard Schema plan review --- ...07-22-standard-schema-event-definitions.md | 212 +++++++++++++++--- ...tandard-schema-event-definitions-design.md | 24 +- 2 files changed, 196 insertions(+), 40 deletions(-) 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 index 11df369..294e909 100644 --- a/docs/superpowers/plans/2026-07-22-standard-schema-event-definitions.md +++ b/docs/superpowers/plans/2026-07-22-standard-schema-event-definitions.md @@ -93,6 +93,8 @@ expectTypeOf["button_clicked"]>() .toEqualTypeOf(); expectTypeOf["session_started"]>() .toEqualTypeOf(); +expectTypeOf<(typeof events)["buttonClicked"]["category"]>() + .toEqualTypeOf<"engagement">(); // @ts-expect-error primitive typed(); @@ -242,12 +244,19 @@ export type EventOutputMap> = { Then define readable exported tuple helpers: ```typescript -type ClientTrackArgs = EventInputMap[N] extends undefined +export type ClientTrackArgs< + R extends EventRegistry, + N extends EventName, +> = EventInputMap[N] extends undefined ? [eventName: N] : [eventName: N, properties: EventInputMap[N]]; -type ServerTrackArgs = EventInputMap[N] extends undefined - ? [eventName: N] | [eventName: N, properties: undefined, options: O] +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]; ``` @@ -301,6 +310,23 @@ object checks, propertyless normalization/rejection, default drop, opt-in throw, 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 @@ -341,22 +367,30 @@ the validator exception as `cause` because it may retain input. - [ ] **Step 4: Implement `resolveEvent()`** ```typescript -export interface ResolvedEvent { - readonly name: string; +export interface ResolvedEvent< + R extends EventRegistry, + N extends EventName, +> { + readonly name: N; readonly category: EventCategory; - readonly properties: Record; + readonly properties: EventOutputMap[N]; } -export async function resolveEvent( - registry: EventRegistry, - eventName: string, +export async function resolveEvent< + R extends EventRegistry, + N extends EventName, +>( + registry: R, + eventName: N, input: unknown, + inputProvided: boolean, validation: ValidationConfig | undefined, debug: boolean, -): Promise; +): Promise | undefined>; ``` -Lookup, handle propertyless/type/schema definitions, await validation, and +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, @@ -421,6 +455,22 @@ Change `UserContext` and `EventContext` constraints from 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 @@ -435,9 +485,14 @@ export class BrowserAnalytics< ``` Store registry, validation config, debug, and `enabled !== false`. Short-circuit -before initialization when disabled; call `resolveEvent`; return on drop; build -`BaseEvent` from resolved name/category/properties. Remove category derivation. -Preserve browser/session context and provider routing. +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** @@ -494,7 +549,9 @@ schema transformations, disabled mode, drop/throw, and these propertyless forms: ```typescript await analytics.track("session_started"); -await analytics.track("session_started", undefined, { userId: "user_123" }); +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 }); ``` @@ -502,6 +559,9 @@ 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** @@ -538,10 +598,19 @@ export class ServerAnalytics< Store registry/validation/debug/enabled. Short-circuit when disabled, retain the initialized check, resolve before context construction, use definition category, -and remove category derivation. Widen ordinary-interface output only where the -shared provider transport requires `Record`. For propertyless -events, only omitted or `undefined` properties are valid; options occupy -argument three. +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** @@ -568,7 +637,11 @@ await analytics.track( ``` Do not add an untyped public tracking method; runtime lookup/validation must -still reject or drop unknown proxy events. +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** @@ -591,8 +664,10 @@ Expected: all code tests, typecheck, and lint PASS before commit. - 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` +- Modify: `package.json`, `tsconfig.json` **Interfaces:** - Consumes: completed registry-bound adapters. @@ -625,10 +700,21 @@ 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/client.test.ts test/server.test.ts +pnpm vitest run test/standard-schema-integration.test.ts test/type-diagnostics.test.ts test/client.test.ts test/server.test.ts pnpm build ``` @@ -647,11 +733,10 @@ EventMapFromCollection EventDefinition ExtractEventName ExtractEventProperties -AnyEventName -AnyEventProperties ``` -Retain transport/provider types. Export `defineEvents`, `typed`, +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. @@ -663,20 +748,18 @@ Implement `scripts/verify-package.mjs` with this complete flow: ```javascript import { execFileSync } from "node:child_process"; import { - mkdirSync, 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 contextDirectory = join(root, ".context"); -mkdirSync(contextDirectory, { recursive: true }); const consumerDirectory = mkdtempSync( - join(contextDirectory, "package-consumer-"), + join(tmpdir(), "trakoo-package-consumer-"), ); let tarballPath; @@ -754,13 +837,75 @@ try { 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; it verifies the validator-free package path. +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** @@ -770,7 +915,7 @@ pnpm typecheck pnpm lint pnpm build pnpm verify:package -git add package.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 scripts/verify-package.mjs +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" ``` @@ -938,9 +1083,12 @@ Create `.changeset/standard-schema-events.md`: "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. +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** ```bash 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 index 7caa6ff..6aff3f6 100644 --- 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 @@ -116,11 +116,16 @@ 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 event a one-argument `track()` call. Supplying a second argument -is a compile-time error; JavaScript or type-bypassing callers that supply one +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. -This special case avoids `typed<{}>()`, whose TypeScript meaning is broader than -an exact empty property object. +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 @@ -368,8 +373,10 @@ 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 second `track()` argument; -they do not require an empty generic or empty object literal. +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. @@ -436,8 +443,9 @@ failure policy. - 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 `track()` call and reject a - supplied properties argument. +- 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. From a843567ded2ec60a417ed0c484298dd516689088 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 15:23:23 +0200 Subject: [PATCH 05/20] feat: add Standard Schema event registry --- package.json | 8 +- pnpm-lock.yaml | 12 ++ src/core/events/index.ts | 2 + src/core/events/registry.ts | 115 +++++++++++++++ src/core/events/schema.ts | 78 ++++++++++ test/events.test.ts | 281 ++++++++++++++++++++++++------------ 6 files changed, 402 insertions(+), 94 deletions(-) create mode 100644 src/core/events/registry.ts create mode 100644 src/core/events/schema.ts diff --git a/package.json b/package.json index 2cd62d8..f7c84fa 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,8 @@ "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", @@ -101,5 +102,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..b8136c0 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 @@ -66,6 +70,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 +1765,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} @@ -7369,6 +7379,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 diff --git a/src/core/events/index.ts b/src/core/events/index.ts index 0148bd4..1f51008 100644 --- a/src/core/events/index.ts +++ b/src/core/events/index.ts @@ -1,5 +1,7 @@ // Re-export core types export * from "./types.js"; +export * from "./schema.js"; +export * from "./registry.js"; // Generic type helpers for users to create their own strongly typed events export type CreateEventDefinition< diff --git a/src/core/events/registry.ts b/src/core/events/registry.ts new file mode 100644 index 0000000..eae84c7 --- /dev/null +++ b/src/core/events/registry.ts @@ -0,0 +1,115 @@ +import type { StandardSchemaV1, StandardTypedV1 } from "@standard-schema/spec"; +import type { EventCategory } from "./types.js"; +import type { + EventProperties, + NoPropertiesMarker, + 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"); + +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(); + + 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); + } + + Object.defineProperty(definitions, registryBrand, { + value: definitionsByName, + enumerable: false, + writable: false, + }); + + return definitions as EventRegistry; +} + +export function getEventDefinition( + registry: EventRegistry, + name: string, +): RuntimeEventDefinition | undefined { + return registry[registryBrand].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..ef5bf04 --- /dev/null +++ b/src/core/events/schema.ts @@ -0,0 +1,78 @@ +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 function isTypeMarker(value: unknown): value is TypeMarker { + return typeof value === "object" && value !== null && "kind" in value && value.kind === "type"; +} + +export function isNoPropertiesMarker( + value: unknown, +): value is NoPropertiesMarker { + return typeof value === "object" && value !== null && "kind" in value && value.kind === "none"; +} + +export function isStandardSchema( + value: unknown, +): value is StandardSchemaV1 { + if (typeof value !== "object" || value === null || !("~standard" in value)) { + return false; + } + + const standard = value["~standard"]; + return ( + typeof standard === "object" && + standard !== null && + "validate" in standard && + typeof standard.validate === "function" + ); +} 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), + }, }); From 8e6debe2541780af39c0be980dd303edc699f489 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 15:33:35 +0200 Subject: [PATCH 06/20] feat: validate Standard Schema event properties --- src/core/events/index.ts | 1 + src/core/events/validation.ts | 221 ++++++++++++++++ test/event-validation.test.ts | 458 ++++++++++++++++++++++++++++++++++ 3 files changed, 680 insertions(+) create mode 100644 src/core/events/validation.ts create mode 100644 test/event-validation.test.ts diff --git a/src/core/events/index.ts b/src/core/events/index.ts index 1f51008..e8fb5b0 100644 --- a/src/core/events/index.ts +++ b/src/core/events/index.ts @@ -2,6 +2,7 @@ export * from "./types.js"; export * from "./schema.js"; export * from "./registry.js"; +export * from "./validation.js"; // Generic type helpers for users to create their own strongly typed events export type CreateEventDefinition< diff --git a/src/core/events/validation.ts b/src/core/events/validation.ts new file mode 100644 index 0000000..575845b --- /dev/null +++ b/src/core/events/validation.ts @@ -0,0 +1,221 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { + getEventDefinition, + type EventDefinitions, + type EventName, + type EventOutputMap, + type EventRegistry, +} from "./registry.js"; +import { + isNoPropertiesMarker, + isStandardSchema, + isTypeMarker, +} from "./schema.js"; +import type { EventCategory } from "./types.js"; + +export type AnalyticsValidationErrorCode = + | "unknown_event" + | "invalid_properties" + | "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 }; + }); +} + +async function applyFailurePolicy( + 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; +} + +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 definition = getEventDefinition(registry, eventName); + if (!definition) { + return applyFailurePolicy( + new AnalyticsValidationError("unknown_event", eventName), + validation, + debug, + ); + } + + if (isNoPropertiesMarker(definition.properties)) { + if (inputProvided) { + return applyFailurePolicy( + new AnalyticsValidationError("invalid_properties", eventName), + validation, + debug, + ); + } + return { + name: eventName, + category: definition.category, + properties: {} as EventOutputMap[N], + }; + } + + if (isTypeMarker(definition.properties)) { + if (!isPropertyObject(input)) { + return applyFailurePolicy( + new AnalyticsValidationError("invalid_properties", eventName), + validation, + debug, + ); + } + return { + name: eventName, + category: definition.category, + properties: input as EventOutputMap[N], + }; + } + + if (!isStandardSchema(definition.properties)) { + return applyFailurePolicy( + new AnalyticsValidationError("invalid_properties", eventName), + validation, + debug, + ); + } + + let result: StandardSchemaV1.Result; + try { + result = await definition.properties["~standard"].validate(input); + } catch { + return applyFailurePolicy( + new AnalyticsValidationError("validator_failure", eventName), + validation, + debug, + ); + } + + try { + if ("issues" in result && result.issues) { + return applyFailurePolicy( + new AnalyticsValidationError( + "invalid_properties", + eventName, + normalizeIssues(result.issues), + ), + validation, + debug, + ); + } + + if (!("value" in result) || !isPropertyObject(result.value)) { + return applyFailurePolicy( + new AnalyticsValidationError("invalid_output", eventName), + validation, + debug, + ); + } + + return { + name: eventName, + category: definition.category, + properties: result.value as EventOutputMap[N], + }; + } catch { + return applyFailurePolicy( + new AnalyticsValidationError("validator_failure", eventName), + validation, + debug, + ); + } +} diff --git a/test/event-validation.test.ts b/test/event-validation.test.ts new file mode 100644 index 0000000..55ba867 --- /dev/null +++ b/test/event-validation.test.ts @@ -0,0 +1,458 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { + AnalyticsValidationError, + defineEvents, + noProperties, + resolveEvent, + typed, + type EventName, +} 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 invalidResult( + value: unknown, +): StandardSchemaV1.Result { + return { value } as StandardSchemaV1.Result; +} + +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("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"); + }); +}); From 3db4b7d89149380e50cdb485fe79d39da1d36303 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 15:45:24 +0200 Subject: [PATCH 07/20] feat: bind client analytics to event registries --- src/adapters/client/browser-analytics.ts | 110 ++++-- src/client.ts | 176 ++------- src/client/index.ts | 7 - src/core/events/types.ts | 12 +- test/client-analytics.test.ts | 465 +++++++++++------------ test/client.test.ts | 16 +- test/provider-routing.test.ts | 79 +++- 7 files changed, 418 insertions(+), 447 deletions(-) diff --git a/src/adapters/client/browser-analytics.ts b/src/adapters/client/browser-analytics.ts index 4248a32..a22144c 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. @@ -65,12 +87,16 @@ export class BrowserAnalytics< * 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 @@ -263,6 +289,7 @@ export class BrowserAnalytics< * ``` */ async initialize(): Promise { + if (!this.enabled) return; if (!isBrowser()) return; if (this.initialized) return; @@ -374,6 +401,8 @@ export class BrowserAnalytics< * ``` */ identify(userId: string, traits?: TUserTraits): void { + if (!this.enabled) return; + this.userId = userId; this.userTraits = traits; @@ -384,7 +413,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, + ); } } } @@ -464,17 +496,30 @@ export class BrowserAnalytics< * } * ``` */ - 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 +550,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 +628,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 +646,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 +707,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 +722,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); } } } @@ -728,6 +780,8 @@ export class BrowserAnalytics< * ``` */ reset(): void { + if (!this.enabled) return; + this.userId = undefined; this.userTraits = undefined; this.sessionId = this.generateSessionId(); @@ -774,9 +828,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 +935,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/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..8109eaf 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,13 +1,6 @@ // Main client analytics export { createClientAnalytics, - createAnalytics, - getAnalytics, - track, - identify, - pageView, - pageLeave, - reset, type ClientAnalyticsConfig, } from "@/client.js"; diff --git a/src/core/events/types.ts b/src/core/events/types.ts index e007b1f..e8a5b0e 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?: { 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..5552f24 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -2,14 +2,16 @@ import { describe, it, expect } from "vitest"; import * as Analytics from "@/client/index"; describe("trakoo exports", () => { - it("should export client analytics functions", () => { + 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/provider-routing.test.ts b/test/provider-routing.test.ts index 5ce7b53..3c2136d 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, @@ -383,10 +423,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 +442,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 +465,7 @@ describe("Event-Level Routing - Client", () => { it("should exclude events with 'excludeEvents' option", async () => { analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -448,6 +489,7 @@ describe("Event-Level Routing - Client", () => { it("should match events with 'eventPatterns' glob patterns", async () => { analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -458,7 +500,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 +510,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 +545,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 +577,7 @@ describe("Event-Level Routing - Client", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); analytics = createClientAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -633,7 +682,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,7 +692,9 @@ 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"); }); @@ -721,7 +774,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 +784,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); From 303f5710fc3a91f2d6f60a8c13fb7fe773b4e602 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 16:17:45 +0200 Subject: [PATCH 08/20] feat: bind server analytics to event registries --- src/adapters/server/server-analytics.ts | 198 +++++++--- src/providers/proxy/server.ts | 65 ++-- src/server.ts | 80 ++--- src/server/index.ts | 1 + test/provider-routing.test.ts | 14 +- test/proxy-server.test.ts | 186 +++++++++- test/server-analytics.test.ts | 458 ++++++++++++++---------- test/server.test.ts | 32 +- tsconfig.json | 1 + 9 files changed, 725 insertions(+), 310 deletions(-) diff --git a/src/adapters/server/server-analytics.ts b/src/adapters/server/server-analytics.ts index 6f8a76a..7779a72 100644 --- a/src/adapters/server/server-analytics.ts +++ b/src/adapters/server/server-analytics.ts @@ -1,17 +1,71 @@ -import type { AnyEventName, AnyEventProperties } from "@/core/events/index.js"; +import { + getEventDefinition, + type EventDefinitions, + type EventName, + type EventOutputMap, + type EventRegistry, + type ServerTrackArgs, +} from "@/core/events/registry.js"; +import { isNoPropertiesMarker } from "@/core/events/schema.js"; import type { - AnalyticsConfig, AnalyticsProvider, BaseEvent, - EventCategory, EventContext, ProviderConfigOrProvider, ProviderMethod, UserContext, } from "@/core/events/types.js"; +import { + resolveEvent, + type ValidationConfig, +} from "@/core/events/validation.js"; -// Default event map type - allows any event with any properties when no specific map is provided -type DefaultEventMap = Record>; +export const serverAnalyticsRegistry: unique symbol = Symbol( + "trakoo.serverAnalytics.registry", +); + +export interface ServerAnalyticsRegistryAccess< + TRegistry extends EventRegistry, +> { + readonly [serverAnalyticsRegistry]: TRegistry; +} + +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 +79,16 @@ interface NormalizedProviderConfig { } export class ServerAnalytics< - TEventMap extends Record> = DefaultEventMap, - TUserTraits extends Record = Record, + TRegistry extends EventRegistry, + TUserTraits extends object = Record, > { 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. @@ -62,9 +120,18 @@ export class ServerAnalytics< * 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); + Object.defineProperty(this, serverAnalyticsRegistry, { + value: config.events, + enumerable: false, + writable: false, + }); } /** @@ -259,6 +326,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 +383,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); @@ -450,36 +525,67 @@ export class ServerAnalytics< * } * ``` */ - 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 event: BaseEvent = { - action: eventName, - category: this.getCategoryFromEventName(eventName), - properties: properties as Record, + const argumentValues: readonly unknown[] = args; + const eventName = args[0]; + const definition = getEventDefinition(this.registry, eventName); + const secondArgument = argumentValues[1]; + let input = secondArgument; + let inputProvided = args.length > 1; + let options: ServerTrackOptions | undefined; + + if (definition && isNoPropertiesMarker(definition.properties)) { + if ( + args.length === 2 && + isServerTrackOptions(secondArgument) + ) { + options = secondArgument; + input = undefined; + inputProvided = false; + } else if (args.length === 1) { + input = undefined; + inputProvided = false; + } + } else { + const thirdArgument = argumentValues[2]; + if (isServerTrackOptions(thirdArgument)) { + options = thirdArgument; + } + } + + const resolved = await resolveEvent( + this.registry, + eventName, + input, + inputProvided, + this.validation, + this.debug, + ); + if (!resolved) return; + + 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) @@ -491,7 +597,10 @@ export class ServerAnalytics< ) .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 +680,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 +768,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); } } } @@ -758,6 +871,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 +886,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/providers/proxy/server.ts b/src/providers/proxy/server.ts index 8dff6a2..8cc5e28 100644 --- a/src/providers/proxy/server.ts +++ b/src/providers/proxy/server.ts @@ -1,5 +1,17 @@ import type { EventContext } from "@/core/events/types.js"; -import type { ServerAnalytics } from "@/server.js"; +import { + getEventDefinition, + type EventDefinitions, + type EventName, + type EventRegistry, +} from "@/core/events/registry.js"; +import { isNoPropertiesMarker } from "@/core/events/schema.js"; +import { + serverAnalyticsRegistry, + type ServerAnalytics, + type ServerAnalyticsRegistryAccess, + type ServerTrackOptions, +} from "@/adapters/server/server-analytics.js"; import type { ProxyPayload } from "./types.js"; /** @@ -44,14 +56,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 +105,35 @@ export async function ingestProxyEvents< }, } as EventContext; - // Convert BaseEvent back to track() parameters - await analytics.track( + const eventName = event.event.action as EventName; + const options: ServerTrackOptions = { + userId: event.event.userId, + sessionId: event.event.sessionId, + context: enrichedContext, + }; + const definition = getEventDefinition( + ( + analytics as unknown as ServerAnalyticsRegistryAccess + )[serverAnalyticsRegistry], 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, - }, ); + + if (definition && isNoPropertiesMarker(definition.properties)) { + const rawArguments = [eventName, options] as const; + await analytics.track(...(rawArguments as never)); + } else { + const rawArguments = [ + eventName, + event.event.properties as never, + options, + ] as const; + await analytics.track(...(rawArguments as never)); + } break; } case "identify": { - await analytics.identify(event.userId, event.traits); + await analytics.identify(event.userId, event.traits as TUserTraits); break; } @@ -207,13 +229,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..3f828e1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3,6 +3,7 @@ export { createServerAnalytics, ServerAnalytics, type ServerAnalyticsConfig, + type ServerTrackOptions, } from "@/server.js"; // Server-side providers diff --git a/test/provider-routing.test.ts b/test/provider-routing.test.ts index 3c2136d..d8dc624 100644 --- a/test/provider-routing.test.ts +++ b/test/provider-routing.test.ts @@ -285,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 }); @@ -304,6 +304,7 @@ describe("Provider Routing - Server", () => { it("should call all methods on simple provider (default behavior)", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [provider1], }); @@ -322,6 +323,7 @@ describe("Provider Routing - Server", () => { it("should only call specified methods with 'methods' option", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -348,6 +350,7 @@ describe("Provider Routing - Server", () => { it("should skip specified methods with 'exclude' option", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -374,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, @@ -608,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 }); @@ -627,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, @@ -649,6 +654,7 @@ describe("Event-Level Routing - Server", () => { it("should exclude events with 'excludeEvents' option", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -672,6 +678,7 @@ describe("Event-Level Routing - Server", () => { it("should match events with 'eventPatterns' glob patterns", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -700,6 +707,7 @@ describe("Event-Level Routing - Server", () => { it("should combine method and event routing", async () => { analytics = createServerAnalytics({ + events: clientRoutingEvents, providers: [ { provider: provider1, @@ -726,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, @@ -755,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, diff --git a/test/proxy-server.test.ts b/test/proxy-server.test.ts index d936c92..e571662 100644 --- a/test/proxy-server.test.ts +++ b/test/proxy-server.test.ts @@ -6,14 +6,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 +114,41 @@ 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("should process identify events", async () => { const payload: ProxyPayload = { events: [ @@ -84,6 +177,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 +647,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..9a272bf 100644 --- a/test/server-analytics.test.ts +++ b/test/server-analytics.test.ts @@ -1,287 +1,367 @@ -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 }>(), + }, + 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(), + })), }, -} as const satisfies EventCollection< - Record> ->; +}); + +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" } }, + }); + }); + + it("uses the exact definition category instead of deriving one", async () => { + await analytics.track("test_event", { data: "test" }); - 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"); + 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" }, }, }, - ); + }); + + await withDefaultContext.track("test_event", {}); - expect(mockProvider.calls.track).toHaveLength(1); - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.context?.user?.email).toBe("priority@example.com"); + 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("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" }, + }); - expect(mockProvider.calls.pageView).toHaveLength(1); - const pageView = mockProvider.calls.pageView[0]; - expect(pageView.properties).toEqual({ - path: "/dashboard", - title: "Dashboard", + await transformed.track("normalized_event", { label: " SIGN UP " }); + + expect(mockProvider.calls.track[0].event.properties).toEqual({ + normalizedLabel: "sign up", }); - expect(pageView.context?.device).toEqual({ - type: "desktop", - os: "macOS", + expect(secondProvider.calls.track[0].event.properties).toEqual({ + normalizedLabel: "sign up", }); }); - 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("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; + }; - await multiAnalytics.track("user_signed_up", { userId: "user-123" }); + await runtimeAnalytics.track("normalized_event", { label: 42 }); + await runtimeAnalytics.track("not_registered", { secret: true }); - expect(mockProvider.calls.track).toHaveLength(1); - expect(mockProvider2.calls.track).toHaveLength(1); + expect(mockProvider.calls.track).toHaveLength(0); + expect(onError.mock.calls.map(([error]) => error.code)).toEqual([ + "invalid_properties", + "unknown_event", + ]); }); - 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("throws validation failures when configured", async () => { + const runtimeAnalytics = analytics as unknown as { + track(name: string, properties?: unknown): Promise; + }; - disabledAnalytics.track("user_signed_up", { userId: "user-123" }); + 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); + }); - expect(disabledProvider.calls.track).toHaveLength(0); + 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("should extract category from event name", async () => { - await analytics.track("custom_action", { data: "test" }); + 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(() => {}); - const trackedEvent = mockProvider.calls.track[0]; - expect(trackedEvent.event.category).toBe("custom"); + 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("should use default category for events without underscore", async () => { - await analytics.track("singleword", { data: "test" }); + 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..2171cf7 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1,6 +1,36 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { ServerAnalytics as ServerAnalyticsClass } from "@/adapters/server/server-analytics"; +import { defineEvents, noProperties, typed } from "@/core/events"; import * as ServerAnalytics from "@/server"; +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(); diff --git a/tsconfig.json b/tsconfig.json index 443d0da..54b5570 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, From 8cd443dfed076c37df783a51688f5e70b4a2b3d8 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 16:28:05 +0200 Subject: [PATCH 09/20] fix: validate server analytics replay arguments --- src/adapters/server/server-analytics.ts | 15 ++++++- src/core/events/validation.ts | 19 ++++----- src/providers/proxy/server.ts | 15 ++++++- test/proxy-server.test.ts | 47 ++++++++++++++++++++++ test/server-analytics.test.ts | 52 +++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 12 deletions(-) diff --git a/src/adapters/server/server-analytics.ts b/src/adapters/server/server-analytics.ts index 7779a72..b61edb1 100644 --- a/src/adapters/server/server-analytics.ts +++ b/src/adapters/server/server-analytics.ts @@ -16,6 +16,8 @@ import type { UserContext, } from "@/core/events/types.js"; import { + AnalyticsValidationError, + applyValidationFailurePolicy, resolveEvent, type ValidationConfig, } from "@/core/events/validation.js"; @@ -555,10 +557,19 @@ export class ServerAnalytics< input = undefined; inputProvided = false; } - } else { + } else if (definition) { const thirdArgument = argumentValues[2]; - if (isServerTrackOptions(thirdArgument)) { + if (thirdArgument === undefined) { + options = undefined; + } else if (isServerTrackOptions(thirdArgument)) { options = thirdArgument; + } else { + await applyValidationFailurePolicy( + new AnalyticsValidationError("invalid_properties", eventName), + this.validation, + this.debug, + ); + return; } } diff --git a/src/core/events/validation.ts b/src/core/events/validation.ts index 575845b..77f924a 100644 --- a/src/core/events/validation.ts +++ b/src/core/events/validation.ts @@ -87,7 +87,8 @@ function normalizeIssues( }); } -async function applyFailurePolicy( +/** @internal Shared adapter failure-policy entry point; not part of the public API. */ +export async function applyValidationFailurePolicy( error: AnalyticsValidationError, validation: ValidationConfig | undefined, debug: boolean, @@ -129,7 +130,7 @@ export async function resolveEvent< ): Promise | undefined> { const definition = getEventDefinition(registry, eventName); if (!definition) { - return applyFailurePolicy( + return applyValidationFailurePolicy( new AnalyticsValidationError("unknown_event", eventName), validation, debug, @@ -138,7 +139,7 @@ export async function resolveEvent< if (isNoPropertiesMarker(definition.properties)) { if (inputProvided) { - return applyFailurePolicy( + return applyValidationFailurePolicy( new AnalyticsValidationError("invalid_properties", eventName), validation, debug, @@ -153,7 +154,7 @@ export async function resolveEvent< if (isTypeMarker(definition.properties)) { if (!isPropertyObject(input)) { - return applyFailurePolicy( + return applyValidationFailurePolicy( new AnalyticsValidationError("invalid_properties", eventName), validation, debug, @@ -167,7 +168,7 @@ export async function resolveEvent< } if (!isStandardSchema(definition.properties)) { - return applyFailurePolicy( + return applyValidationFailurePolicy( new AnalyticsValidationError("invalid_properties", eventName), validation, debug, @@ -178,7 +179,7 @@ export async function resolveEvent< try { result = await definition.properties["~standard"].validate(input); } catch { - return applyFailurePolicy( + return applyValidationFailurePolicy( new AnalyticsValidationError("validator_failure", eventName), validation, debug, @@ -187,7 +188,7 @@ export async function resolveEvent< try { if ("issues" in result && result.issues) { - return applyFailurePolicy( + return applyValidationFailurePolicy( new AnalyticsValidationError( "invalid_properties", eventName, @@ -199,7 +200,7 @@ export async function resolveEvent< } if (!("value" in result) || !isPropertyObject(result.value)) { - return applyFailurePolicy( + return applyValidationFailurePolicy( new AnalyticsValidationError("invalid_output", eventName), validation, debug, @@ -212,7 +213,7 @@ export async function resolveEvent< properties: result.value as EventOutputMap[N], }; } catch { - return applyFailurePolicy( + return applyValidationFailurePolicy( new AnalyticsValidationError("validator_failure", eventName), validation, debug, diff --git a/src/providers/proxy/server.ts b/src/providers/proxy/server.ts index 8cc5e28..a5faba7 100644 --- a/src/providers/proxy/server.ts +++ b/src/providers/proxy/server.ts @@ -35,6 +35,15 @@ export interface IngestProxyEventsConfig { onError?: (error: unknown) => void; } +function isEmptyPropertyObject(value: unknown): value is object { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.keys(value).length === 0 + ); +} + /** * Ingests events from ProxyProvider and replays them through server analytics * @@ -118,7 +127,11 @@ export async function ingestProxyEvents< event.event.action, ); - if (definition && isNoPropertiesMarker(definition.properties)) { + if ( + definition && + isNoPropertiesMarker(definition.properties) && + isEmptyPropertyObject(event.event.properties) + ) { const rawArguments = [eventName, options] as const; await analytics.track(...(rawArguments as never)); } else { diff --git a/test/proxy-server.test.ts b/test/proxy-server.test.ts index e571662..116471f 100644 --- a/test/proxy-server.test.ts +++ b/test/proxy-server.test.ts @@ -149,6 +149,53 @@ describe("Proxy Server Ingestion", () => { }); }); + 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("should process identify events", async () => { const payload: ProxyPayload = { events: [ diff --git a/test/server-analytics.test.ts b/test/server-analytics.test.ts index 9a272bf..a5b9f18 100644 --- a/test/server-analytics.test.ts +++ b/test/server-analytics.test.ts @@ -241,6 +241,58 @@ describe("Server Analytics", () => { }, ); + it.each([ + ["null", null], + ["an array", []], + ["a primitive", "user_123"], + ["unknown option keys", { unexpected: true }], + ])( + "throws invalid_properties 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_properties" }); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "invalid_properties" }), + ); + 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_properties" }), + ); + expect(mockProvider.calls.track).toHaveLength(0); + }); + it("delivers transformed schema output to every routed provider", async () => { const secondProvider = new MockAnalyticsProvider({ enabled: true }); const transformed = createServerAnalytics({ From 463b6f0ff02f15c6fa16a620a54192278736f3fc Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 16:54:44 +0200 Subject: [PATCH 10/20] feat: publish the Standard Schema event API --- package.json | 6 +- pnpm-lock.yaml | 9 ++ scripts/verify-package.mjs | 181 +++++++++++++++++++++++ src/client/index.ts | 34 ++++- src/core/events/index.ts | 38 ----- src/core/events/types.ts | 17 --- src/index.ts | 40 ++++- src/server/index.ts | 34 ++++- test/client.test.ts | 49 +++++- test/fixtures/invalid-event-usage.ts | 20 +++ test/server.test.ts | 19 ++- test/standard-schema-integration.test.ts | 50 +++++++ test/type-diagnostics.test.ts | 62 ++++++++ tsconfig.json | 7 +- 14 files changed, 483 insertions(+), 83 deletions(-) create mode 100644 scripts/verify-package.mjs create mode 100644 test/fixtures/invalid-event-usage.ts create mode 100644 test/standard-schema-integration.test.ts create mode 100644 test/type-diagnostics.test.ts diff --git a/package.json b/package.json index f7c84fa..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", @@ -95,6 +96,7 @@ "@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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8136c0..a7183e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,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 @@ -1967,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==} @@ -7549,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/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..6b8b303 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,181 @@ +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 { + 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 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 }); +} diff --git a/src/client/index.ts b/src/client/index.ts index 8109eaf..f920a88 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -4,6 +4,28 @@ export { 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 @@ -15,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 e8fb5b0..8cc48e9 100644 --- a/src/core/events/index.ts +++ b/src/core/events/index.ts @@ -4,44 +4,6 @@ export * from "./schema.js"; export * from "./registry.js"; export * from "./validation.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>; - // Generic types for any event system export type AnyEventName = string; export type AnyEventProperties = Record; diff --git a/src/core/events/types.ts b/src/core/events/types.ts index e8a5b0e..716d212 100644 --- a/src/core/events/types.ts +++ b/src/core/events/types.ts @@ -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/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/server/index.ts b/src/server/index.ts index 3f828e1..dec721d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -6,6 +6,28 @@ export { 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"; @@ -15,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.test.ts b/test/client.test.ts index 5552f24..b8cb597 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1,7 +1,54 @@ -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("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).not.toHaveProperty("createAnalytics"); 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/server.test.ts b/test/server.test.ts index 2171cf7..13de376 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1,7 +1,12 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import type { ServerAnalytics as ServerAnalyticsClass } from "@/adapters/server/server-analytics"; -import { defineEvents, noProperties, typed } from "@/core/events"; -import * as ServerAnalytics from "@/server"; +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"; @@ -36,4 +41,14 @@ describe("trakoo/server exports", () => { 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..afa90e3 --- /dev/null +++ b/test/type-diagnostics.test.ts @@ -0,0 +1,62 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("public event type diagnostics", () => { + it("reports concise public names for invalid event usage", () => { + const root = resolve(import.meta.dirname, ".."); + const fixture = resolve( + root, + "test/fixtures/invalid-event-usage.ts", + ); + const configDirectory = mkdtempSync( + join(tmpdir(), "trakoo-type-diagnostics-"), + ); + const configPath = join(configDirectory, "tsconfig.json"); + 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: [], + }), + ); + const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + const result = spawnSync( + pnpm, + [ + "exec", + "tsc", + "--pretty", + "false", + "--project", + configPath, + ], + { cwd: root, encoding: "utf8" }, + ); + 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 54b5570..b14c331 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,5 +20,10 @@ }, "compileOnSave": true, "include": ["src", "test"], - "exclude": ["node_modules", "dist", "coverage"] + "exclude": [ + "node_modules", + "dist", + "coverage", + "test/fixtures/invalid-event-usage.ts" + ] } From 237615082a3b2b46a988c8e3e8cf13f0549e1498 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 17:04:54 +0200 Subject: [PATCH 11/20] fix: verify the root bundle import graph --- scripts/package-verification.mjs | 73 +++++++++++++++++++++++++++++++ scripts/verify-package.mjs | 17 ++++--- test/package-verification.test.ts | 49 +++++++++++++++++++++ test/type-diagnostics.test.ts | 72 ++++++++++++++++-------------- 4 files changed, 169 insertions(+), 42 deletions(-) create mode 100644 scripts/package-verification.mjs create mode 100644 test/package-verification.test.ts 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 index 6b8b303..0fa89f9 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -8,6 +8,7 @@ import { 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( @@ -129,21 +130,19 @@ try { } } - const rootBundle = readFileSync( - join(consumerDirectory, "node_modules/trakoo/dist/index.js"), - "utf8", + const installedDist = join( + consumerDirectory, + "node_modules/trakoo/dist", ); - for (const prohibitedImport of [ + assertRootBundleNeutral(join(installedDist, "index.js"), installedDist, [ ...concreteValidators, "posthog-js", "posthog-node", "@openpanel/sdk", "@openpanel/web", - ]) { - if (rootBundle.includes(prohibitedImport)) { - throw new Error(`root bundle includes ${prohibitedImport}`); - } - } + "@bentonow/bento-node-sdk", + "@emitkit/js", + ]); // Prove root event helpers load without optional provider packages present. run("npm", ["prune", "--omit=optional"], consumerDirectory); 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/type-diagnostics.test.ts b/test/type-diagnostics.test.ts index afa90e3..de74a3b 100644 --- a/test/type-diagnostics.test.ts +++ b/test/type-diagnostics.test.ts @@ -1,12 +1,13 @@ import { spawnSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +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(import.meta.dirname, ".."); + const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const fixture = resolve( root, "test/fixtures/invalid-event-usage.ts", @@ -15,38 +16,43 @@ describe("public event type diagnostics", () => { join(tmpdir(), "trakoo-type-diagnostics-"), ); const configPath = join(configDirectory, "tsconfig.json"); - 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: [], - }), - ); const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; - const result = spawnSync( - pnpm, - [ - "exec", - "tsc", - "--pretty", - "false", - "--project", - configPath, - ], - { cwd: root, encoding: "utf8" }, - ); - rmSync(configDirectory, { recursive: true, force: true }); + 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/); From bdd52d96359dba28ca9a88397c7766b19a7b1797 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 17:16:29 +0200 Subject: [PATCH 12/20] docs: migrate core guides to Standard Schema events --- readme.md | 1085 +++-------------- www/content/docs/(Getting Started)/index.mdx | 23 +- .../docs/(Getting Started)/installation.mdx | 15 +- .../docs/(Getting Started)/quick-start.mdx | 148 +-- .../docs/core-concepts/client-vs-server.mdx | 36 +- www/content/docs/core-concepts/events.mdx | 287 +++-- .../docs/core-concepts/identifying-users.mdx | 33 +- www/content/docs/core-concepts/index.mdx | 15 +- www/content/docs/core-concepts/providers.mdx | 7 + .../docs/core-concepts/type-safety.mdx | 245 ++-- 10 files changed, 603 insertions(+), 1291 deletions(-) diff --git a/readme.md b/readme.md index 7b5adff..f73f0d4 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 + debug: import.meta.env.DEV }); +``` -// Track events with full type safety - event names and properties are typed! +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. + +```typescript analytics.track('user_signed_up', { userId: 'user-123', - email: 'user@example.com', - plan: 'pro', - referralSource: 'google' -}); - -// 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 - -// Identify users - user context is automatically included in all subsequent events -analytics.identify('user-123', { - email: 'user@example.com', - name: 'John Doe', + 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: {...} } +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 sanitized 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. It never retains the event payload. With `debug: true` and no `onError`, the fallback warning contains only the code, event name, and issue paths, so invalid values are not logged. -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/www/content/docs/(Getting Started)/index.mdx b/www/content/docs/(Getting Started)/index.mdx index 079e008..6d34bdc 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,6 +74,8 @@ 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 diff --git a/www/content/docs/(Getting Started)/installation.mdx b/www/content/docs/(Getting Started)/installation.mdx index 0ff787c..a8e89c7 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', {}); +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..117a796 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. @@ -147,22 +149,10 @@ document.querySelector('#signup')?.addEventListener('click', () => { -With the console provider, the click logs an event like this: - -```typescript -{ - event: { - category: 'engagement', - action: 'button_clicked', - properties: { buttonId: 'signup-cta', location: 'hero' } - } -} -``` +The console provider receives an event whose `action` is `button_clicked` and whose `properties` match the value you passed. ## 5. Connect a real provider -Swap the console provider for a real one. This example uses [PostHog](/docs/providers/posthog) because it supports both browser and server tracking, but the same event definitions work with every provider. - ```bash pnpm install posthog-js ``` @@ -170,9 +160,10 @@ pnpm install posthog-js ```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'; -export const analytics = createClientAnalytics({ +export const analytics = createClientAnalytics({ + events: appEvents, providers: [ new PostHogClientProvider({ token: import.meta.env.VITE_POSTHOG_KEY, @@ -183,11 +174,11 @@ export const analytics = createClientAnalytics({ }); ``` -Your components don't change — they still call `analytics.track('button_clicked', …)`. +Your components do not change. -## 6. Identify your users +## 6. Identify users -Client analytics is stateful. Call `identify()` once — usually after login — and later events carry the current user until you call `reset()`. +Client analytics is stateful. `identify()` sets the current user until `reset()`. ```typescript analytics.identify('user_123', { @@ -202,18 +193,17 @@ analytics.track('user_signed_up', { }); ``` -See [Identifying Users](/docs/core-concepts/identifying-users) for traits, typing, and logout. +See [Identifying Users](/docs/core-concepts/identifying-users) to type custom traits. ## 7. Track critical events on the server -Some events — payments, signups, anything you must not lose to an ad-blocker — belong on the server. Server analytics is stateless: pass user context with each call, and flush before the process exits. - ```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'; -export const serverAnalytics = createServerAnalytics({ +export const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new PostHogServerProvider({ apiKey: process.env.POSTHOG_API_KEY! }) ] @@ -226,27 +216,49 @@ import { serverAnalytics } from '@/lib/server-analytics'; export async function POST(request: Request) { const user = await createUser(await request.json()); - await serverAnalytics.track('user_signed_up', { - email: user.email, - plan: user.plan - }, { - userId: user.id, - user: { email: user.email, traits: { plan: user.plan } } - }); - - await serverAnalytics.shutdown(); + try { + await serverAnalytics.track('user_signed_up', { + email: user.email, + plan: user.plan + }, { + userId: user.id, + user: { email: user.email, traits: { plan: user.plan } } + }); - return Response.json({ ok: true }); + return Response.json({ ok: true }); + } finally { + await serverAnalytics.shutdown(); + } } ``` -:::warning[Always flush on the server] -In serverless environments, call `shutdown()` before the function returns so providers can send queued events. See [Client vs Server](/docs/core-concepts/client-vs-server) for the full picture. +:::warning[Flush on the server] +In serverless environments, call `shutdown()` before the function exits so providers can send queued events. ::: -## Send to more than one provider +## Runtime validation is optional + +`typed()` does not validate individual fields at runtime. For untrusted input, pass a Standard Schema-compatible validator directly. Standard Schema is an interface, not a required validation runtime; libraries such as Zod implement it. + +```typescript +import { defineEvents } from 'trakoo'; +import { z } from 'zod'; + +export const checkoutEvents = defineEvents({ + orderCompleted: { + name: 'order_completed', + category: 'conversion', + properties: z.object({ + orderId: z.string(), + amount: z.coerce.number().positive() + }) + } +}); +``` + +The schema input controls accepted call-site values; its validated output is what providers receive. Read [Events](/docs/core-concepts/events#runtime-validation) for failure policy and ordering details. -A single instance can fan out to several services. Route each provider to only the calls it should receive: +## Send to more than one provider ```typescript import { @@ -255,7 +267,8 @@ import { VisitorsClientProvider } from 'trakoo/providers/client'; -export const analytics = createClientAnalytics({ +export const analytics = createClientAnalytics({ + events: appEvents, providers: [ new PostHogClientProvider({ token: import.meta.env.VITE_POSTHOG_KEY }), { @@ -272,16 +285,13 @@ export const analytics = createClientAnalytics({ ## Next steps - - Set up PostHog, Bento, Pirsch, EmitKit, Visitors, or the Proxy. + + Learn compile-time definitions, runtime validation, and failure policy. - - Send specific methods and events to specific providers. + + Set up each official provider and routing rules. - Decide where each kind of event belongs. - - - Understand the types behind the autocomplete. + Choose the right lifetime and delivery pattern. diff --git a/www/content/docs/core-concepts/client-vs-server.mdx b/www/content/docs/core-concepts/client-vs-server.mdx index 5f26622..0642cd0 100644 --- a/www/content/docs/core-concepts/client-vs-server.mdx +++ b/www/content/docs/core-concepts/client-vs-server.mdx @@ -12,7 +12,7 @@ trakoo ships two entry points: `trakoo/client` for the browser and `trakoo/serve | State | Stateful — `identify()` persists | Stateless — pass context per call | | User context | Set once, applied to later events | Passed with every `track()` | | Logout | Call `reset()` | Nothing to reset | -| Lifetime | One instance per session | One instance per request or worker | +| Lifetime | Application-owned session instance | Application-owned request, worker, or process instance | | Delivery | Fire-and-forget | Await critical events | | Shutdown | Not required | Required in serverless | @@ -40,15 +40,18 @@ Import from `trakoo/client` and create one instance for the session. ```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'; -export const analytics = createClientAnalytics({ +export const analytics = createClientAnalytics({ + events: appEvents, providers: [ new PostHogClientProvider({ token: import.meta.env.VITE_POSTHOG_KEY }) ] }); ``` +The factory returns a fresh instance. trakoo has no global client singleton or module-level tracking helper; your application owns this instance and imports it where needed. + ### Stateful user context The client remembers the current user. Call `identify()` once — usually after login — and every later event carries that context until you call `reset()`. @@ -104,15 +107,18 @@ Import from `trakoo/server`. The server API is stateless, so create an 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'; -export const serverAnalytics = createServerAnalytics({ +export const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new PostHogServerProvider({ apiKey: process.env.POSTHOG_API_KEY! }) ] }); ``` +Each call returns an independent instance bound to `appEvents`; choose its lifetime in application code. A long-running process may own one server instance because user context is supplied per event, while an isolated request or worker may create and shut down its own. + ### Stateless user context There is no `identify()` that persists. Pass the user with each event, because one instance may handle many users. @@ -225,7 +231,7 @@ You get client context — where they clicked, how long they took — alongside ## Import paths -Use the environment-specific entry points. The root `trakoo` package exports shared types only, and there is no combined `trakoo/providers` export. +Use the environment-specific entry points. The root `trakoo` package exports shared types plus environment-neutral helpers such as `defineEvents`, `typed`, and `noProperties`. Factories stay on `trakoo/client` and `trakoo/server`, and there is no combined `trakoo/providers` export. @@ -235,7 +241,7 @@ Use the environment-specific entry points. The root `trakoo` package exports sha import { createClientAnalytics } from 'trakoo/client'; import { PostHogClientProvider } from 'trakoo/providers/client'; -// Avoid — the root package exports shared types only. +// Avoid — factories are environment-specific. import { createClientAnalytics } from 'trakoo'; // Avoid — there is no provider aggregate export. import { PostHogClientProvider } from 'trakoo/providers'; @@ -249,7 +255,7 @@ import { PostHogClientProvider } from 'trakoo/providers'; import { createServerAnalytics } from 'trakoo/server'; import { PostHogServerProvider } from 'trakoo/providers/server'; -// Avoid — the root package exports shared types only. +// Avoid — factories are environment-specific. import { createServerAnalytics } from 'trakoo'; // Avoid — there is no provider aggregate export. import { PostHogServerProvider } from 'trakoo/providers'; @@ -258,6 +264,20 @@ import { PostHogServerProvider } from 'trakoo/providers'; +## Async validation and delivery order + +Schema validation may be asynchronous. If you start several `track()` calls concurrently, a later call whose validator finishes first can reach providers first. Concurrent calls are not delivery-ordered: + +```typescript +// Both start immediately; provider delivery order is not guaranteed. +await Promise.all([ + serverAnalytics.track('first_event', firstInput), + serverAnalytics.track('second_event', secondInput) +]); +``` + +When order matters, await each call before starting the next. Validation failures use their own configured policy; initialization failures and provider failures retain their existing behavior. + ## Framework patterns diff --git a/www/content/docs/core-concepts/events.mdx b/www/content/docs/core-concepts/events.mdx index ac02c37..b606965 100644 --- a/www/content/docs/core-concepts/events.mdx +++ b/www/content/docs/core-concepts/events.mdx @@ -1,234 +1,219 @@ --- title: Events -description: Define type-safe analytics events once and track them across every provider. +description: Define a typed event registry, optionally validate at runtime, and control validation failures. --- -Events are the core of analytics tracking. Each one represents an action in your app — a button click, a signup, a purchase — that you want to measure. In trakoo you define events once, in a typed collection, and every `track()` call is checked against those definitions. +An event registry is the source of truth for the names, categories, and properties your application can track. Both client and server factories require the registry value, so compile-time inference and runtime lookup use the same definitions. ## Defining events -Describe your events in a single collection. The object keys organize your code; the `name` values are what trakoo sends 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({ userSignedUp: { name: 'user_signed_up', category: 'user', - properties: {} as { + properties: typed<{ email: string; plan: 'free' | 'pro' | 'enterprise'; referralSource?: string; - } + }>() }, buttonClicked: { name: 'button_clicked', category: 'engagement', - properties: {} as { + properties: typed<{ buttonId: string; location: string; - } + }>() + }, + sessionStarted: { + name: 'session_started', + category: 'user', + properties: noProperties() } -} as const satisfies EventCollection>>; +}); ``` -The `as const satisfies` pattern is what gives you exact event names, autocomplete on properties, and a compile error when a definition or a `track()` call is wrong. See [Type Safety](/docs/core-concepts/type-safety) for how it works. +The object keys are local labels. The `name` values are stable wire names accepted by `track()` and sent to providers. `defineEvents()` preserves literal types, verifies the definition shape, builds the runtime lookup, and throws immediately if two definitions use the same wire name. + +## Names and categories -## The three parts +Use stable, descriptive names such as `checkout_completed`. Renaming a production event splits its history, so prefer adding a new event and deprecating the old one. -Every event definition has a name, an optional category, and a set of properties. +Every definition requires a category. trakoo autocompletes common categories: -### Name +- `user` +- `navigation` +- `conversion` +- `engagement` +- `error` +- `performance` -The `name` is the string sent to your providers. A few conventions keep your data clean: +Custom strings such as `billing`, `product`, and `ai` are also accepted. -- Use `snake_case` — it matches most analytics tools. -- Use past tense: `button_clicked`, not `button_click`. -- Be descriptive but concise: `checkout_completed`, not `user_clicked_complete_checkout_button`. +## Validator-free properties -Once an event is live, treat its name as stable. Renaming it splits your history — add a new event and deprecate the old one instead. +`typed()` is the primary developer experience. It declares the input and provider-output type without installing or executing a validator: -### Category +```typescript +properties: typed<{ + plan: 'free' | 'pro' | 'enterprise'; + amount: number; + currency: 'USD' | 'EUR' | 'GBP'; +}>() +``` -Categories group related events. The field is optional, but it keeps large collections navigable. trakoo ships a set of common categories: +TypeScript checks the individual properties at call sites. At runtime, trakoo checks only that the supplied value is a non-null, non-array object. Use runtime validation for data from forms, webhooks, queues, or other untrusted boundaries. -- `user` — lifecycle events like signup, login, and profile updates -- `navigation` — page views and navigation -- `conversion` — goals and revenue events -- `engagement` — feature usage and interactions -- `error` — error tracking -- `performance` — performance monitoring +`typed()` accepts object shapes, including nested objects and array-valued fields, but the top-level event properties must be an object. -You can also use your own domain-specific categories such as `product`, `billing`, or `ai`: +## Propertyless events + +Use `noProperties()` when an event carries no custom properties: ```typescript -export const appEvents = { - aiResponseGenerated: { - name: 'ai_response_generated', - category: 'ai', - properties: {} as { - model: string; - tokensUsed: number; - responseTime: number; - } +const lifecycleEvents = defineEvents({ + sessionStarted: { + name: 'session_started', + category: 'user', + properties: noProperties() } -} as const; +}); + +analytics.track('session_started'); ``` -### Properties +Passing `{}` or any other second argument is invalid. Providers receive an empty properties object after trakoo resolves the event. -Properties are the data attached to each event. Type them as narrowly as the data allows — specific types catch mistakes at the call site and keep your reports consistent. +## Runtime validation -```typescript -// Good — narrow types and explicit units -properties: {} as { - plan: 'free' | 'pro' | 'enterprise'; - amount: number; - currency: 'USD' | 'EUR' | 'GBP'; -} - -// Avoid — anything goes, nothing is checked -properties: {} as { - plan: string; - amount: any; - currency: string; -} +Standard Schema is a shared interface implemented by validation libraries. It is not a validator runtime and is not required to use trakoo. When runtime validation or transformation is useful, provide a compatible schema directly. Zod implements Standard Schema: + +```typescript title="lib/commerce-events.ts" +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() + }) + } +}); ``` -Mark optional properties with `?`. +The validator's input type determines what `track()` accepts. Its output type determines what providers receive. Here `amount` can enter as a coercible value, while the provider always receives a positive `number`. Only validated output is delivered. -## Tracking events +Other Standard Schema-compatible libraries work the same way; trakoo does not wrap or adapt them. -With events defined, `track()` takes the event key and its typed properties. Mistype the name or omit a required property, and TypeScript flags it before the event ships. +## Validation failure policy -Client analytics is fire-and-forget, so calls don't block the UI: +Unknown wire names, missing or unexpected properties arguments, schema issues, validator exceptions, and non-object validator outputs are validation failures. -```typescript -import { analytics } from '@/lib/analytics'; +The default policy is **drop**: the call resolves without sending the event to any provider. Opt into strict mode when the caller must handle the failure: -analytics.track('button_clicked', { - buttonId: 'cta', - location: 'hero' +```typescript +import { createClientAnalytics } from 'trakoo/client'; +import { commerceEvents } from './commerce-events'; + +const analytics = createClientAnalytics({ + events: commerceEvents, + providers: [/* ... */], + validation: { + onFailure: 'throw', + onError: (error) => reportValidationFailure(error) + } }); ``` -Server analytics is stateless: pass user context with each call, and flush before the process exits. +`onError` runs and is awaited before trakoo drops or throws. Errors expose a code, event name, and normalized issue messages/paths. They do not retain the original payload. -```typescript -import { serverAnalytics } from '@/lib/server-analytics'; +With `debug: true` and no `onError`, the fallback warning is sanitized to the error code, event name, and issue paths. It does not log invalid values. If `onError` itself throws or rejects, that reporting failure is ignored so it cannot change the selected drop/throw policy. -await serverAnalytics.track('user_signed_up', { - email: 'ada@example.com', - plan: 'pro' -}, { - userId: 'user_123', - user: { email: 'ada@example.com' } -}); +The validation policy applies only to event registry and property validation. Initialization failures and provider failures retain their existing behavior. -await serverAnalytics.shutdown(); -``` +## Async schemas and ordering -See [Client vs Server](/docs/core-concepts/client-vs-server) for where each kind of event belongs. +Validators may return promises. Concurrent calls proceed as their validators finish, so invocation order does not guarantee provider delivery order: -## Organizing events +```typescript +await Promise.all([ + analytics.track('first_event', firstInput), + analytics.track('second_event', secondInput) +]); +``` -For a small app, one file is enough: +Await calls one at a time if order matters: -```typescript title="lib/events.ts" -export const appEvents = { - userSignedUp: { /* ... */ }, - productViewed: { /* ... */ }, - buttonClicked: { /* ... */ } -} as const; +```typescript +await analytics.track('first_event', firstInput); +await analytics.track('second_event', secondInput); ``` -As the collection grows, split by area and merge in an index: +## Tracking -```typescript title="lib/events/index.ts" -import { userEvents } from './user'; -import { productEvents } from './product'; +Create a registry-bound instance; no event generic is needed: -export const appEvents = { - ...userEvents, - ...productEvents -} as const; -``` - -## Common patterns +```typescript title="lib/analytics.ts" +import { createClientAnalytics } from 'trakoo/client'; +import { appEvents } from './events'; -E-commerce apps track the path from view to purchase: +export const analytics = createClientAnalytics({ + events: appEvents, + providers: [/* ... */] +}); -```typescript -export const appEvents = { - productViewed: { - name: 'product_viewed', - category: 'product', - properties: {} as { - productId: string; - price: number; - } - }, - purchaseCompleted: { - name: 'purchase_completed', - category: 'conversion', - properties: {} as { - orderId: string; - total: number; - currency: 'USD' | 'EUR' | 'GBP'; - } - } -} as const; +analytics.track('button_clicked', { + buttonId: 'cta', + location: 'hero' +}); ``` -SaaS apps focus on activation and upgrades: +On the server, pass user context after properties and shut down before a short-lived runtime exits: ```typescript -export const appEvents = { - trialStarted: { - name: 'trial_started', - category: 'conversion', - properties: {} as { - plan: 'pro' | 'enterprise'; - trialDays: number; - } - }, - subscriptionUpgraded: { - name: 'subscription_upgraded', - category: 'conversion', - properties: {} as { - fromPlan: string; - toPlan: string; - price: number; - } - } -} as const; +await serverAnalytics.track('user_signed_up', { + email: 'ada@example.com', + plan: 'pro' +}, { + userId: 'user_123' +}); + +await serverAnalytics.shutdown(); ``` -## Best practices +## Organizing registries -- Define events before you track them. A central, typed collection means autocomplete works everywhere and typos become compile errors. -- Document non-obvious events with a comment on when and why they fire, and who relies on them. -- Keep names stable in production. Add and deprecate rather than rename. +Split large definition objects by domain, then combine them in the single registry passed to factories: -```typescript -export const appEvents = { - // @deprecated Use 'subscription_upgraded' instead - planUpgraded: { /* ... */ }, - subscriptionUpgraded: { /* ... */ } -} as const; +```typescript title="lib/events/index.ts" +import { defineEvents } from 'trakoo'; +import { productEventDefinitions } from './product'; +import { userEventDefinitions } from './user'; + +export const appEvents = defineEvents({ + ...productEventDefinitions, + ...userEventDefinitions +}); ``` +Keep definition fragments as plain objects and call `defineEvents()` once after merging. This guarantees duplicate wire names are checked across the full application registry. + ## Next steps + + See how registry inference flows through track calls and traits. + - See how providers send your events to analytics services. + Learn how validated output fans out to providers. Decide where each event belongs. - - Understand the types behind the autocomplete. - diff --git a/www/content/docs/core-concepts/identifying-users.mdx b/www/content/docs/core-concepts/identifying-users.mdx index 9c3a3ea..eaea3d9 100644 --- a/www/content/docs/core-concepts/identifying-users.mdx +++ b/www/content/docs/core-concepts/identifying-users.mdx @@ -71,15 +71,16 @@ await serverAnalytics.track('project_created', { await serverAnalytics.shutdown(); ``` -Each request stands alone, so a single server instance can safely handle many users. There's no `reset()` — there's nothing stored to clear. +Each call stands alone, so a server instance can safely handle many users. There's no persisted user state to reset. ## Type your traits -Pass a traits type as the second generic argument to type-check `identify()` alongside your events. +Pass a `typed()` trait marker to type-check `identify()` alongside your events. ```typescript title="lib/analytics.ts" import { createClientAnalytics } from 'trakoo/client'; -import type { AppEvents } from './events'; +import { typed } from 'trakoo'; +import { appEvents } from './events'; interface UserTraits { email: string; @@ -89,7 +90,9 @@ interface UserTraits { role?: 'admin' | 'member' | 'viewer'; } -export const analytics = createClientAnalytics({ +export const analytics = createClientAnalytics({ + events: appEvents, + userTraits: typed(), providers: [/* ... */] }); @@ -107,16 +110,24 @@ analytics.identify('user_123', { **Use a stable user ID.** Pick an identifier that never changes — a database ID or UUID — and use the same format everywhere. Emails and session IDs change, which fragments a user's history across identities. ```typescript -analytics.identify('550e8400-e29b-41d4-a716-446655440000', { email: 'ada@example.com' }); // Good +analytics.identify('550e8400-e29b-41d4-a716-446655440000', { + email: 'ada@example.com', + name: 'Ada Lovelace', + plan: 'pro' +}); // Good analytics.identify('ada@example.com', { /* ... */ }); // Avoid — email can change ``` **Keep traits current.** Re-identify with the same ID when a user's data changes; providers merge the update. ```typescript -async function handleUpgrade(userId: string, plan: string) { - await upgradePlan(userId, plan); - analytics.identify(userId, { plan }); +async function handleUpgrade(user: User, plan: UserTraits['plan']) { + await upgradePlan(user.id, plan); + analytics.identify(user.id, { + email: user.email, + name: user.name, + plan + }); } ``` @@ -186,7 +197,11 @@ export async function POST(req: Request) { Turn on `debug` to log identification and the context attached to each event. ```typescript -const analytics = createClientAnalytics({ providers: [/* ... */], debug: true }); +const analytics = createClientAnalytics({ + events: appEvents, + providers: [/* ... */], + debug: true +}); analytics.identify('user_123', { email: 'ada@example.com', plan: 'pro' }); // [Analytics] User identified: user_123 { email: 'ada@example.com', plan: 'pro' } diff --git a/www/content/docs/core-concepts/index.mdx b/www/content/docs/core-concepts/index.mdx index 314ba86..85468ab 100644 --- a/www/content/docs/core-concepts/index.mdx +++ b/www/content/docs/core-concepts/index.mdx @@ -31,16 +31,21 @@ Everything comes back to three moves: define events, create an instance with pro ```typescript // 1. Define events once -export const appEvents = { +import { defineEvents, typed } from 'trakoo'; +import { createClientAnalytics } from 'trakoo/client'; +import { PostHogClientProvider } from 'trakoo/providers/client'; + +export const appEvents = defineEvents({ buttonClicked: { name: 'button_clicked', category: 'engagement', - properties: {} as { buttonId: string } + properties: typed<{ buttonId: string }>() } -} as const satisfies EventCollection>>; +}); // 2. Create an instance with your providers -const analytics = createClientAnalytics({ +const analytics = createClientAnalytics({ + events: appEvents, providers: [new PostHogClientProvider({ token: 'xxx' })] }); @@ -48,6 +53,8 @@ const analytics = createClientAnalytics({ analytics.track('button_clicked', { buttonId: 'cta' }); ``` +The root import supplies environment-neutral registry helpers and shared types. Factories and providers stay on their client/server subpaths. Every factory call returns a fresh application-owned instance; passing `events` gives it the registry and all inferred types. + Client tracking is stateful: `identify()` once, and later events carry that user until you `reset()`. Server tracking is stateless: pass user context with each `track()`, then `shutdown()` to flush. [Client vs Server](/docs/core-concepts/client-vs-server) covers the difference in full. ## Before you start diff --git a/www/content/docs/core-concepts/providers.mdx b/www/content/docs/core-concepts/providers.mdx index 52c1929..66365f3 100644 --- a/www/content/docs/core-concepts/providers.mdx +++ b/www/content/docs/core-concepts/providers.mdx @@ -9,6 +9,7 @@ A provider is a small adapter that translates a trakoo event into the API of a s ```typescript const analytics = createClientAnalytics({ + events: appEvents, providers: [new PostHogClientProvider({ token: 'xxx' })] }); @@ -24,6 +25,7 @@ An analytics instance can hold any number of providers. Each call fans out to al ```typescript const analytics = createClientAnalytics({ + events: appEvents, providers: [ new PostHogClientProvider({ token: 'xxx' }), // product analytics new PirschClientProvider({ identificationCode: 'yyy' }), // privacy-friendly @@ -73,11 +75,16 @@ Turn on `debug` to surface what would otherwise be swallowed: ```typescript const analytics = createClientAnalytics({ + events: appEvents, providers: [new PostHogClientProvider({ token: 'xxx', debug: true })], debug: true }); ``` +Every factory call creates a fresh analytics instance, and provider constructors create provider instances owned by that configuration. trakoo does not keep a process-wide singleton. Create the instance in your application and import that owned instance at call sites. + +Event validation happens before fan-out. A validation failure follows the configured drop/throw policy and sends nothing to any provider. This policy does not change initialization or provider error handling: initialization failures and individual provider failures retain their existing behavior. + ## Import from the right entry point Providers are split by environment. Import browser providers from `trakoo/providers/client` and server providers from `trakoo/providers/server` so nothing server-only ends up in your browser bundle. diff --git a/www/content/docs/core-concepts/type-safety.mdx b/www/content/docs/core-concepts/type-safety.mdx index 9a71b26..a85f8f9 100644 --- a/www/content/docs/core-concepts/type-safety.mdx +++ b/www/content/docs/core-concepts/type-safety.mdx @@ -1,124 +1,103 @@ --- title: Type Safety -description: Define your events once and get autocomplete, compile-time checks, and inferred property types across client and server. +description: Infer event inputs, validated outputs, and user traits from one runtime registry. --- -trakoo is built around one typed event definition. Define your events once, pass the type to your analytics instance, and every `track()` and `identify()` call is checked against it. Misspelled event names, missing properties, and wrong values fail at compile time instead of silently shipping bad data. +trakoo binds each analytics instance to an event registry. Passing that registry value to a factory gives `track()` exact wire names and the correct properties for each name, without event generics or manual collection types. -## Why type safety matters +## Why it matters -Analytics bugs are quiet. A typo in an event name or a property doesn't throw — it records the wrong thing, and you find out weeks later when a funnel comes up empty. +Analytics mistakes are quiet: a typo can create a second event or an unusable property and remain unnoticed until a report is wrong. A registry-bound instance catches them while you write code. ```typescript -// Avoid: no types, so nothing catches these mistakes -analytics.track('user_signedup', { // wrong event name - emai: 'user@example.com', // wrong property - plan: 'premium' // not a valid plan -}); -``` - -With a typed instance, each of those is a compile error before the event ships: - -```typescript -// Good: every argument is checked against your event definition analytics.track('user_signed_up', { - email: 'user@example.com', + email: 'ada@example.com', plan: 'pro' }); -// TypeScript flags: -// - 'user_signedup' is not a known event name -// - 'emai' does not exist on this event's properties -// - 'premium' is not assignable to 'free' | 'pro' | 'enterprise' +// TypeScript rejects: +// - 'user_signedup' because it is not a registry wire name +// - 'emai' because it is not a property +// - 'premium' because it is outside the plan union ``` -## The event definition - -Everything starts with one typed collection of events. The `as const satisfies` combination is what makes the checks work. +## Define the registry ```typescript title="lib/events.ts" -import type { CreateEventDefinition, EventCollection } from 'trakoo'; +import { defineEvents, noProperties, 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'; referralSource?: string; - } + }>() + }, + sessionStarted: { + name: 'session_started', + category: 'user', + properties: noProperties() } -} as const satisfies EventCollection>>; - -export type AppEvents = typeof appEvents; -``` - -Three pieces do the work. - -### `as const` - -Without it, TypeScript widens each literal to its base type — `name` becomes `string`, and `'user'` becomes `string`. `as const` locks every value to its literal type, so trakoo can offer `'user_signed_up'` as an autocomplete suggestion instead of "any string". - -```typescript -const a = { name: 'user_signed_up' }; // name: string -const b = { name: 'user_signed_up' } as const; // name: 'user_signed_up' +}); ``` -### `satisfies EventCollection<...>` +`defineEvents()` preserves the name and category literals while checking every definition. `typed()` declares a validator-free object shape. `noProperties()` declares an event whose call has no properties argument. -`satisfies` checks that your object matches the expected shape without changing its inferred type. You get validation — a malformed event is a compile error — while keeping the exact literal types from `as const`. +No const assertion, collection interface, or exported event generic is required. -### `properties: {} as { ... }` - -Event properties are a type, not a runtime value. `{} as { ... }` hands trakoo the property shape to check `track()` calls against, without allocating a real object at runtime. - -```typescript -properties: {} as { - email: string; // required - plan: 'free' | 'pro'; // union — only these values - referralSource?: string; // optional -} -``` - -## Autocomplete everywhere - -Pass your event type to the analytics instance and every call is driven by it. +## Infer at the factory ```typescript title="lib/analytics.ts" import { createClientAnalytics } from 'trakoo/client'; -import type { AppEvents } from './events'; +import { appEvents } from './events'; -export const analytics = createClientAnalytics({ +export const analytics = createClientAnalytics({ + events: appEvents, providers: [/* ... */] }); ``` -The same generic applies on the server: +The server factory infers from the same value: -```typescript +```typescript title="lib/server-analytics.ts" import { createServerAnalytics } from 'trakoo/server'; -import type { AppEvents } from './events'; +import { appEvents } from './events'; -export const serverAnalytics = createServerAnalytics({ +export const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [/* ... */] }); ``` -From there, your editor completes event names, then the property object for whichever event you picked, then the allowed values for each union property. Each event carries its own property type, so `track('user_signed_up', …)` and `track('button_clicked', …)` expect different shapes. +Both factories return fresh, independent instances. The application owns the returned instance and can export it from its own module for call sites to import. + +## Per-event call signatures + +The registry produces a different tuple for each wire name: ```typescript analytics.track('user_signed_up', { - email: 'ada@example.com' - // compile error: property 'plan' is missing + email: 'ada@example.com', + plan: 'pro' }); + +analytics.track('session_started'); ``` -## Type your user traits +The first call requires properties. The second rejects a properties argument. On the server, property-bearing events accept an optional third options argument, while propertyless events accept options directly as the second argument. -`createClientAnalytics` takes a second generic for user traits. It types `identify()` the same way the first generic types `track()`. +## Type user traits + +Traits use the same validator-free marker: ```typescript +import { typed } from 'trakoo'; +import { createClientAnalytics } from 'trakoo/client'; +import { appEvents } from './events'; + interface UserTraits { email: string; name: string; @@ -127,7 +106,9 @@ interface UserTraits { role?: 'admin' | 'member' | 'viewer'; } -const analytics = createClientAnalytics({ +const analytics = createClientAnalytics({ + events: appEvents, + userTraits: typed(), providers: [/* ... */] }); @@ -136,43 +117,21 @@ analytics.identify('user_123', { name: 'Ada Lovelace', plan: 'pro', role: 'admin' - // wrong: true // compile error — 'wrong' is not a UserTrait }); ``` -:::note -Traits are covered end to end in [Identifying Users](/docs/core-concepts/identifying-users). -::: - -## Event categories - -Every event has a `category`. trakoo ships a set of common ones through the `EventCategory` type, and you can add your own with `as const`. - -```typescript -import type { EventCategory } from 'trakoo'; - -const category: EventCategory = 'user'; -// built-in: 'engagement' | 'user' | 'navigation' | 'error' | 'performance' | 'conversion' - -export const appEvents = { - aiGenerated: { - name: 'ai_generated', - category: 'ai' as const, // custom category - properties: {} as { model: string } - } -} as const; -``` +`userTraits` changes trait typing only. The event registry is still inferred from `events`. ## Complex property types -Properties are ordinary TypeScript types, so nested objects, arrays, and unions all work and stay checked at the call site. +Nested objects, array-valued fields, optional fields, and unions work naturally inside the top-level object shape: ```typescript -export const appEvents = { +const commerceEvents = defineEvents({ purchaseCompleted: { name: 'purchase_completed', category: 'conversion', - properties: {} as { + properties: typed<{ orderId: string; currency: 'USD' | 'EUR' | 'GBP'; items: Array<{ productId: string; quantity: number }>; @@ -180,59 +139,107 @@ export const appEvents = { city: string; country: string; }; - } + }>() }, paymentProcessed: { name: 'payment_processed', category: 'conversion', - properties: {} as { + properties: typed<{ method: 'card' | 'paypal' | 'crypto'; amount: number; cardDetails?: { last4: string; brand: string }; - } + }>() + } +}); +``` + +The top-level properties type itself must be a non-array, non-callable object shape. + +## Input and provider-output types + +With `typed()`, input and output are both `T`. A direct Standard Schema validator can have different input and output types because it parses or transforms data: + +```typescript +import { defineEvents } from 'trakoo'; +import { z } from 'zod'; + +const commerceEvents = defineEvents({ + orderCompleted: { + name: 'order_completed', + category: 'conversion', + properties: z.object({ + orderId: z.string(), + amount: z.coerce.number().positive() + }) } -} as const satisfies EventCollection>>; +}); ``` -## Extract types for reuse +Standard Schema is an interface supported by validator libraries, not a required trakoo runtime. The schema input drives `track()` arguments; its validated output is what providers receive. -Because the definition is a typed value, you can pull a single event's property type out with `typeof` and reuse it in a helper, a form handler, or a shared function signature. +Use the shared type maps when another function needs one side explicitly: ```typescript -type UserSignedUpProps = typeof appEvents.userSignedUp.properties; -// { email: string; plan: 'free' | 'pro' | 'enterprise'; referralSource?: string } +import type { EventInputMap, EventOutputMap } from 'trakoo'; -function trackSignup(props: UserSignedUpProps) { - analytics.track('user_signed_up', props); +type OrderInput = EventInputMap['order_completed']; +type OrderOutput = EventOutputMap['order_completed']; + +function trackOrder(input: OrderInput) { + return commerceAnalytics.track('order_completed', input); } ``` -## Best practices +`OrderInput` follows the schema input. `OrderOutput` is the parsed object delivered to providers. -Always end your collection with `as const satisfies EventCollection<...>`. `as const` keeps the literal types; `satisfies` validates the shape. Drop either and the types widen back to `string`, taking your autocomplete with them. +## Event categories + +The `EventCategory` shared type includes common categories while allowing domain-specific strings: ```typescript -// Good — literal types preserved, shape validated -export const appEvents = { /* ... */ } as const satisfies EventCollection>>; +import { defineEvents, typed, type EventCategory } from 'trakoo'; + +const category: EventCategory = 'user'; -// Avoid — types widen to string, autocomplete is lost -export const appEvents = { /* ... */ }; +const aiEvents = defineEvents({ + responseGenerated: { + name: 'ai_response_generated', + category: 'ai', + properties: typed<{ model: string }>() + } +}); ``` -- Prefer specific unions over `string`. `plan: 'free' | 'pro' | 'enterprise'` catches typos that `plan: string` waves through. -- Mark optional properties with `?` rather than `| undefined`, so callers can omit them entirely. -- Export `type AppEvents = typeof appEvents` next to your definitions, and import it wherever you create an analytics instance. +## Compile time and runtime + +Compile-time checks protect typed call sites. At runtime: + +- `defineEvents()` provides the registry lookup. +- `typed()` checks only that a property-bearing event receives an object. +- `noProperties()` rejects a supplied properties argument. +- A direct Standard Schema validator validates and can transform individual fields. + +Validation failures drop by default or throw when configured. They do not retain the input payload. See [Events](/docs/core-concepts/events#validation-failure-policy) for the full policy. + +## Best practices + +- Prefer `typed()` until runtime validation or transformation has a concrete purpose. +- Use specific unions instead of broad `string` values when the domain is closed. +- Mark optional properties with `?`. +- Use `noProperties()` instead of an empty object type. +- Pass the same registry value through `events` everywhere; do not add factory event generics. +- Use `userTraits: typed()` for custom traits. ## Next steps - How event definitions map to what providers receive. + Learn runtime registry and validation behavior. - Type user traits and attach them to events. + Attach typed user context to events. - - See typed events wired into a real app. + + Use the same registry in both environments. From ba38b4324aea45d2e1a26ef5cd3ff37d8bf880dd Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 17:23:27 +0200 Subject: [PATCH 13/20] docs: address core guide review feedback --- readme.md | 4 ++-- .../docs/core-concepts/client-vs-server.mdx | 23 ++++++++++++++---- www/content/docs/core-concepts/events.mdx | 24 ++++++++++++++----- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/readme.md b/readme.md index f73f0d4..ed198f6 100644 --- a/readme.md +++ b/readme.md @@ -164,7 +164,7 @@ export const commerceEvents = defineEvents({ 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. -Validation failures are dropped by default. For strict handling, opt into throwing and report the sanitized error: +Validation failures are dropped by default. For strict handling, opt into throwing and report the normalized, payload-free error: ```typescript const analytics = createClientAnalytics({ @@ -177,7 +177,7 @@ const analytics = createClientAnalytics({ }); ``` -`AnalyticsValidationError` contains a code, event name, and normalized issue messages/paths. It never retains the event payload. With `debug: true` and no `onError`, the fallback warning contains only the code, event name, and issue paths, so invalid values are not logged. +`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. 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. diff --git a/www/content/docs/core-concepts/client-vs-server.mdx b/www/content/docs/core-concepts/client-vs-server.mdx index 0642cd0..bda999f 100644 --- a/www/content/docs/core-concepts/client-vs-server.mdx +++ b/www/content/docs/core-concepts/client-vs-server.mdx @@ -13,7 +13,7 @@ trakoo ships two entry points: `trakoo/client` for the browser and `trakoo/serve | User context | Set once, applied to later events | Passed with every `track()` | | Logout | Call `reset()` | Nothing to reset | | Lifetime | Application-owned session instance | Application-owned request, worker, or process instance | -| Delivery | Fire-and-forget | Await critical events | +| Delivery | Returns a promise; often handled in the background | Await critical events | | Shutdown | Not required | Required in serverless | ## When to use each @@ -73,18 +73,31 @@ analytics.reset(); See [Identifying Users](/docs/core-concepts/identifying-users) for traits, typing, and logout details. -### Fire-and-forget +### Handle the tracking promise -Client tracking is non-blocking. Don't await it — let events send in the background so the UI stays responsive. +Client `track()` always returns `Promise`. UI flows often start that work without delaying navigation, but should explicitly handle a possible rejection. Rejections can matter when strict validation is configured or initialization fails. ```typescript function handleClick() { - analytics.track('button_clicked', { buttonId: 'cta' }); + void analytics + .track('button_clicked', { buttonId: 'cta' }) + .catch((error) => reportTrackingFailure(error)); navigateTo('/checkout'); } ``` -Awaiting `track()` on the client makes the user wait on an analytics request for no benefit. +Await `track()` when completion matters or when the caller should handle a strict validation failure inline: + +```typescript +async function trackBeforeContinuing() { + try { + await analytics.track('button_clicked', { buttonId: 'cta' }); + continueWorkflow(); + } catch (error) { + reportTrackingFailure(error); + } +} +``` ### Page views diff --git a/www/content/docs/core-concepts/events.mdx b/www/content/docs/core-concepts/events.mdx index b606965..8eebf42 100644 --- a/www/content/docs/core-concepts/events.mdx +++ b/www/content/docs/core-concepts/events.mdx @@ -189,20 +189,32 @@ await serverAnalytics.shutdown(); ## Organizing registries -Split large definition objects by domain, then combine them in the single registry passed to factories: +For most applications, keep one central `defineEvents()` registry. If a large application must split definitions by domain, call `defineEvents()` where each literal is created so its wire names stay exact, then select the named definitions into the final application registry. ```typescript title="lib/events/index.ts" import { defineEvents } from 'trakoo'; -import { productEventDefinitions } from './product'; -import { userEventDefinitions } from './user'; +import { productEvents } from './product'; +import { userEvents } from './user'; export const appEvents = defineEvents({ - ...productEventDefinitions, - ...userEventDefinitions + productViewed: productEvents.productViewed, + userSignedUp: userEvents.userSignedUp }); ``` -Keep definition fragments as plain objects and call `defineEvents()` once after merging. This guarantees duplicate wire names are checked across the full application registry. +```typescript title="lib/events/product.ts" +import { defineEvents, typed } from 'trakoo'; + +export const productEvents = defineEvents({ + productViewed: { + name: 'product_viewed', + category: 'engagement', + properties: typed<{ productId: string }>() + } +}); +``` + +Define `userEvents` the same way in its domain module. Selecting definitions by name preserves their literal wire names, while the final `defineEvents()` call checks duplicate wire names across the application registry. Do not treat registries as arbitrary mergeable objects. ## Next steps From 14336905f89979a0746023e21a4295574412cb02 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 17:32:19 +0200 Subject: [PATCH 14/20] docs: add Standard Schema migration guidance --- .changeset/standard-schema-events.md | 5 + www/content/docs/guides/index.mdx | 5 +- www/content/docs/guides/meta.ts | 2 +- www/content/docs/guides/nextjs.mdx | 30 +-- .../docs/guides/standard-schema-migration.mdx | 176 ++++++++++++++++++ www/content/docs/guides/sveltekit.mdx | 40 ++-- www/content/docs/providers/bento.mdx | 4 + www/content/docs/providers/custom.mdx | 2 + www/content/docs/providers/emitkit.mdx | 6 + www/content/docs/providers/index.mdx | 14 ++ www/content/docs/providers/openpanel.mdx | 4 + www/content/docs/providers/pirsch.mdx | 6 + www/content/docs/providers/posthog.mdx | 4 + www/content/docs/providers/proxy.mdx | 11 +- www/content/docs/providers/visitors.mdx | 2 + 15 files changed, 273 insertions(+), 38 deletions(-) create mode 100644 .changeset/standard-schema-events.md create mode 100644 www/content/docs/guides/standard-schema-migration.mdx diff --git a/.changeset/standard-schema-events.md b/.changeset/standard-schema-events.md new file mode 100644 index 0000000..19557ca --- /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. 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/www/content/docs/guides/index.mdx b/www/content/docs/guides/index.mdx index 5feae1d..0f7762b 100644 --- a/www/content/docs/guides/index.mdx +++ b/www/content/docs/guides/index.mdx @@ -10,6 +10,9 @@ These guides show how to wire trakoo into a specific framework end to end: where ## Available guides + + Migrate legacy event collections and the client singleton to runtime event registries. + Next.js 13+ with the App Router, Server Components, and Server Actions. @@ -26,7 +29,7 @@ trakoo has no framework requirement, so the pattern is the same everywhere: 2. Create a server analytics instance for server code with `createServerAnalytics()`. 3. Track events on the instance that matches where the code runs. -If your framework isn't listed yet, start from [Quick Start](/docs/quick-start) and read [Client vs Server](/docs/core-concepts/client-vs-server) to decide where each event belongs. The same typed event definitions work in any framework. +If your framework isn't listed yet, start from [Quick Start](/docs/quick-start) and read [Client vs Server](/docs/core-concepts/client-vs-server) to decide where each event belongs. The same runtime event registry works in any framework. :::note[More guides coming] Want a guide for the framework you use? [Open an issue](https://github.com/multiplehats/trakoo/issues) and let us know. diff --git a/www/content/docs/guides/meta.ts b/www/content/docs/guides/meta.ts index bb81029..92cb485 100644 --- a/www/content/docs/guides/meta.ts +++ b/www/content/docs/guides/meta.ts @@ -3,5 +3,5 @@ import { defineMeta } from "blume"; export default defineMeta({ title: "Framework Guides", icon: "code", - pages: ["index", "nextjs", "sveltekit"], + pages: ["index", "standard-schema-migration", "nextjs", "sveltekit"], }); diff --git a/www/content/docs/guides/nextjs.mdx b/www/content/docs/guides/nextjs.mdx index 2683018..cf1e9c7 100644 --- a/www/content/docs/guides/nextjs.mdx +++ b/www/content/docs/guides/nextjs.mdx @@ -42,33 +42,33 @@ POSTHOG_API_KEY=your-posthog-api-key ## Define your events -Describe every event once, in a file both the client and server import. This is the single source of truth that gives you autocomplete and type checking on every `track()` call. +Describe every event once, in a file both the client and server import. This runtime registry is the single source of truth that gives you autocomplete and type checking on every `track()` call. ```typescript title="lib/events.ts" -import type { CreateEventDefinition, EventCollection } from 'trakoo'; +import { defineEvents, 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'; - } + }>() } -} as const satisfies EventCollection>>; - -export type AppEvents = typeof appEvents; +}); ``` +`typed()` keeps this primary setup validator-free. To validate or transform properties at runtime, pass any Standard Schema-compatible validator instead; see [Runtime validation](/docs/guides/standard-schema-migration#runtime-validation). + ## Client analytics Create the browser instance from `trakoo/client`. It's stateful: it initializes in the background, and later events carry the current user until you reset. @@ -76,9 +76,10 @@ Create the browser instance from `trakoo/client`. It's stateful: it initializes ```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'; -export const analytics = createClientAnalytics({ +export const analytics = createClientAnalytics({ + events: appEvents, providers: [ new PostHogClientProvider({ token: process.env.NEXT_PUBLIC_POSTHOG_KEY!, @@ -196,9 +197,10 @@ Use `trakoo/server` for events you cannot afford to lose to an ad-blocker — si ```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'; -export const serverAnalytics = createServerAnalytics({ +export const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new PostHogServerProvider({ apiKey: process.env.POSTHOG_API_KEY!, diff --git a/www/content/docs/guides/standard-schema-migration.mdx b/www/content/docs/guides/standard-schema-migration.mdx new file mode 100644 index 0000000..3209da2 --- /dev/null +++ b/www/content/docs/guides/standard-schema-migration.mdx @@ -0,0 +1,176 @@ +--- +title: Standard Schema migration +description: Migrate legacy typed event collections to trakoo's runtime event registry. +--- + +This release replaces compile-time-only event collections with a registry that trakoo can inspect at runtime. Factories infer their event names and properties from that registry, so applications no longer pass event generics or export event collection types. + +## What changed + +Before, an event collection relied on type assertions and an explicit factory generic: + +```typescript +export const appEvents = { + buttonClicked: { + name: "button_clicked", + category: "engagement", + properties: {} as { buttonId: string }, + }, +} as const satisfies EventCollection< + Record> +>; + +const analytics = createClientAnalytics({ providers }); +``` + +After, define a runtime registry and pass its value to the factory: + +```typescript +export const appEvents = defineEvents({ + buttonClicked: { + name: "button_clicked", + category: "engagement", + properties: typed<{ buttonId: string }>(), + }, +}); + +const analytics = createClientAnalytics({ + events: appEvents, + providers, +}); +``` + +The obsolete event collection helper types and generic-only factory signatures have been removed. Import `defineEvents()`, `typed()`, and `noProperties()` from the root `trakoo` entry point; keep importing factories and providers from their client or server subpaths. + +## Type-only migration + +Before, property shapes were asserted onto empty objects: + +```typescript +export const appEvents = { + buttonClicked: { + name: "button_clicked", + category: "engagement", + properties: {} as { + buttonId: string; + location: string; + }, + }, +}; +``` + +After, `typed()` records the same compile-time shape without installing a validator: + +```typescript +import { defineEvents, typed } from "trakoo"; + +export const appEvents = defineEvents({ + buttonClicked: { + name: "button_clicked", + category: "engagement", + properties: typed<{ + buttonId: string; + location: string; + }>(), + }, +}); +``` + +`typed()` does not perform runtime validation. For an event with no properties, use `noProperties()` and call `track()` without a properties argument. + +## Runtime validation + +Before, a type assertion checked callers during compilation but accepted unchecked JavaScript values at runtime: + +```typescript +properties: {} as { + orderId: string; + amount: number; +} +``` + +After, use a Standard Schema-compatible validator directly: + +```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 schema supplies the input accepted by `track()` and the validated output delivered to every provider. Standard Schema is an interface, not a required validator dependency; compatible Zod, Valibot, ArkType, and other validators work without a trakoo adapter. + +## Validation failures + +Before, the generic-only factory had no runtime registry to apply a common policy to unknown events or invalid properties: + +```typescript +const analytics = createClientAnalytics({ providers }); +``` + +After, client and server analytics drop validation failures by default and can report sanitized metadata: + +```typescript +const analytics = createClientAnalytics({ + events: appEvents, + providers: [provider], + validation: { + onFailure: "drop", + onError(error) { + // Send sanitized metadata to application observability. + }, + }, +}); +``` + +Opt into strict behavior when a test or workflow should reject invalid analytics: + +```typescript +const analytics = createServerAnalytics({ + events: appEvents, + providers: [provider], + validation: { onFailure: "throw" }, +}); +``` + +`AnalyticsValidationError` contains the event name, a stable error code, and normalized issue paths and messages. It never retains the submitted properties object. Validation failures are resolved before routing, so providers receive either the same normalized output or no event. Initialization and provider-delivery failures keep their existing behavior. + +## Client singleton migration + +Before, application code could use the module-level client singleton and convenience functions: + +```typescript +import { createAnalytics, track } from "trakoo/client"; + +createAnalytics({ providers }); +track("button_clicked", { buttonId: "signup" }); +``` + +After, the application owns and exports one registry-bound instance: + +```typescript title="lib/analytics.ts" +import { createClientAnalytics } from "trakoo/client"; +import { appEvents } from "./events"; + +export const analytics = createClientAnalytics({ + events: appEvents, + providers, +}); +``` + +```typescript +import { analytics } from "./analytics"; + +analytics.track("button_clicked", { buttonId: "signup" }); +``` + +The compatibility alias, singleton getter and reset hook, and module-level tracking helpers have been removed. Import the owned instance wherever the application tracks events. diff --git a/www/content/docs/guides/sveltekit.mdx b/www/content/docs/guides/sveltekit.mdx index feb369d..2b91f45 100644 --- a/www/content/docs/guides/sveltekit.mdx +++ b/www/content/docs/guides/sveltekit.mdx @@ -35,50 +35,50 @@ POSTHOG_API_KEY=your-posthog-api-key ## Define your events -Describe every event once in a shared file so the client and server instances stay in sync. +Describe every event once in a shared runtime registry so the client and server instances stay in sync. ```typescript title="src/lib/events.ts" -import type { CreateEventDefinition, EventCollection } from 'trakoo'; +import { defineEvents, typed } from 'trakoo'; -export const appEvents = { +export const appEvents = defineEvents({ pageViewed: { name: 'page_viewed', category: 'navigation', - properties: {} as { + properties: typed<{ path: string; title: string; - } + }>() }, buttonClicked: { name: 'button_clicked', category: 'engagement', - properties: {} as { + properties: typed<{ buttonId: string; location: string; - } + }>() }, userSignedUp: { name: 'user_signed_up', category: 'user', - properties: {} as { + properties: typed<{ email: string; plan: 'free' | 'pro' | 'enterprise'; - } + }>() }, apiRequest: { name: 'api_request', category: 'system', - properties: {} as { + properties: typed<{ path: string; method: string; - } + }>() } -} as const satisfies EventCollection>>; - -export type AppEvents = typeof appEvents; +}); ``` +`typed()` keeps this primary setup validator-free. To validate or transform properties at runtime, pass any Standard Schema-compatible validator instead; see [Runtime validation](/docs/guides/standard-schema-migration#runtime-validation). + ## Client analytics Create one stateful client instance and import it wherever you track in the browser. @@ -87,9 +87,10 @@ Create one stateful client instance and import it wherever you track in the brow import { createClientAnalytics } from 'trakoo/client'; import { PostHogClientProvider } from 'trakoo/providers/client'; import { PUBLIC_POSTHOG_KEY, PUBLIC_POSTHOG_HOST } from '$env/static/public'; -import type { AppEvents } from './events'; +import { appEvents } from './events'; -export const analytics = createClientAnalytics({ +export const analytics = createClientAnalytics({ + events: appEvents, providers: [ new PostHogClientProvider({ token: PUBLIC_POSTHOG_KEY, @@ -182,9 +183,10 @@ import { createServerAnalytics } from 'trakoo/server'; import { PostHogServerProvider } from 'trakoo/providers/server'; import { POSTHOG_API_KEY } from '$env/static/private'; import { PUBLIC_POSTHOG_HOST } from '$env/static/public'; -import type { AppEvents } from './events'; +import { appEvents } from './events'; -export const serverAnalytics = createServerAnalytics({ +export const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new PostHogServerProvider({ apiKey: POSTHOG_API_KEY, @@ -386,7 +388,7 @@ export const GET: RequestHandler = async () => { **`$env` import errors.** Use static imports — `PUBLIC_*` from `$env/static/public`, secrets from `$env/static/private` — rather than `$env/dynamic/*`, so values are inlined at build time. -**Type errors on the analytics functions.** Import the client from `trakoo/client` and the server from `trakoo/server`. The root `trakoo` entry exports types only, not the `createClientAnalytics`/`createServerAnalytics` factories. +**Type errors on the analytics functions.** Import the client from `trakoo/client` and the server from `trakoo/server`. The root `trakoo` entry exports shared types and environment-neutral event helpers; factories remain on their client and server subpaths. ## Next steps diff --git a/www/content/docs/providers/bento.mdx b/www/content/docs/providers/bento.mdx index f0f198c..3f7d02a 100644 --- a/www/content/docs/providers/bento.mdx +++ b/www/content/docs/providers/bento.mdx @@ -44,8 +44,10 @@ import { BentoClientProvider, PostHogClientProvider } from 'trakoo/providers/client'; +import { appEvents } from '@/lib/events'; const analytics = createClientAnalytics({ + events: appEvents, providers: [ new PostHogClientProvider({ token: import.meta.env.VITE_POSTHOG_KEY }), { @@ -70,8 +72,10 @@ import { BentoServerProvider, PirschServerProvider } from 'trakoo/providers/server'; +import { appEvents } from '@/lib/events'; const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new PirschServerProvider({ hostname: 'example.com', diff --git a/www/content/docs/providers/custom.mdx b/www/content/docs/providers/custom.mdx index 0bb3f68..a76e001 100644 --- a/www/content/docs/providers/custom.mdx +++ b/www/content/docs/providers/custom.mdx @@ -85,8 +85,10 @@ A custom provider goes in the `providers` array like any built-in one, and it wo ```typescript import { createClientAnalytics } from 'trakoo/client'; import { CustomProvider } from './custom-provider'; +import { appEvents } from '@/lib/events'; const analytics = createClientAnalytics({ + events: appEvents, providers: [ new CustomProvider({ apiKey: 'your-api-key', debug: true }) ] diff --git a/www/content/docs/providers/emitkit.mdx b/www/content/docs/providers/emitkit.mdx index 87d641c..853109e 100644 --- a/www/content/docs/providers/emitkit.mdx +++ b/www/content/docs/providers/emitkit.mdx @@ -28,8 +28,10 @@ EmitKit is server-only — its SDK has no browser package. To capture browser ev ```typescript title="lib/server-analytics.ts" import { createServerAnalytics } from 'trakoo/server'; import { EmitKitServerProvider } from 'trakoo/providers/server'; +import { appEvents } from '@/lib/events'; export const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new EmitKitServerProvider({ apiKey: process.env.EMITKIT_API_KEY!, // starts with 'emitkit_' @@ -100,8 +102,10 @@ Send browser events through your own API, then forward them to EmitKit on the se ```typescript title="lib/analytics.ts" import { createClientAnalytics } from 'trakoo/client'; import { ProxyProvider } from 'trakoo/providers/client'; +import { appEvents } from '@/lib/events'; export const analytics = createClientAnalytics({ + events: appEvents, providers: [new ProxyProvider({ endpoint: '/api/analytics' })] }); @@ -114,8 +118,10 @@ import { EmitKitServerProvider, ingestProxyEvents } from 'trakoo/providers/server'; +import { appEvents } from '@/lib/events'; const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new EmitKitServerProvider({ apiKey: process.env.EMITKIT_API_KEY!, diff --git a/www/content/docs/providers/index.mdx b/www/content/docs/providers/index.mdx index 7f4a107..0336212 100644 --- a/www/content/docs/providers/index.mdx +++ b/www/content/docs/providers/index.mdx @@ -54,8 +54,10 @@ import { PostHogClientProvider, VisitorsClientProvider } from 'trakoo/providers/client'; +import { appEvents } from '@/lib/events'; const analytics = createClientAnalytics({ + events: appEvents, providers: [ new PostHogClientProvider({ token: import.meta.env.VITE_POSTHOG_KEY @@ -86,7 +88,15 @@ The application emits one typed event. trakoo fans it out to each enabled provid Some providers should not receive every call. Bento is usually better for identified user lifecycle events than anonymous page views. EmitKit is usually better for selected events than high-volume click streams. Wrap a provider in a config object to control exactly which methods and events reach it. ```typescript +import { createClientAnalytics } from 'trakoo/client'; +import { + BentoClientProvider, + PostHogClientProvider +} from 'trakoo/providers/client'; +import { appEvents } from '@/lib/events'; + const analytics = createClientAnalytics({ + events: appEvents, providers: [ new PostHogClientProvider({ token: import.meta.env.VITE_POSTHOG_KEY }), { @@ -119,8 +129,10 @@ Use the proxy when browser events should go through your domain before reaching ```typescript title="lib/analytics.ts" import { createClientAnalytics } from 'trakoo/client'; import { ProxyProvider } from 'trakoo/providers/client'; +import { appEvents } from '@/lib/events'; export const analytics = createClientAnalytics({ + events: appEvents, providers: [ new ProxyProvider({ endpoint: '/api/events', @@ -136,8 +148,10 @@ import { EmitKitServerProvider, createProxyHandler } from 'trakoo/providers/server'; +import { appEvents } from '@/lib/events'; const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new EmitKitServerProvider({ apiKey: process.env.EMITKIT_API_KEY!, diff --git a/www/content/docs/providers/openpanel.mdx b/www/content/docs/providers/openpanel.mdx index d695a12..6347ce4 100644 --- a/www/content/docs/providers/openpanel.mdx +++ b/www/content/docs/providers/openpanel.mdx @@ -26,8 +26,10 @@ Use `OpenPanelClientProvider` from `trakoo/providers/client`. Browser code needs ```typescript title="lib/analytics.ts" import { createClientAnalytics } from 'trakoo/client'; import { OpenPanelClientProvider } from 'trakoo/providers/client'; +import { appEvents } from '@/lib/events'; export const analytics = createClientAnalytics({ + events: appEvents, providers: [ new OpenPanelClientProvider({ clientId: import.meta.env.VITE_OPENPANEL_CLIENT_ID @@ -70,8 +72,10 @@ Use `OpenPanelServerProvider` from `trakoo/providers/server`. Server analytics i ```typescript title="lib/server-analytics.ts" import { createServerAnalytics } from 'trakoo/server'; import { OpenPanelServerProvider } from 'trakoo/providers/server'; +import { appEvents } from '@/lib/events'; export const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new OpenPanelServerProvider({ clientId: process.env.OPENPANEL_CLIENT_ID!, diff --git a/www/content/docs/providers/pirsch.mdx b/www/content/docs/providers/pirsch.mdx index f1e5aab..7aa67cc 100644 --- a/www/content/docs/providers/pirsch.mdx +++ b/www/content/docs/providers/pirsch.mdx @@ -30,8 +30,10 @@ Pass your Pirsch identification code and the hostname registered in your Pirsch ```typescript title="lib/analytics.ts" import { createClientAnalytics } from 'trakoo/client'; import { PirschClientProvider } from 'trakoo/providers/client'; +import { appEvents } from '@/lib/events'; export const analytics = createClientAnalytics({ + events: appEvents, providers: [ new PirschClientProvider({ identificationCode: 'your-pirsch-identification-code', @@ -55,8 +57,10 @@ Use a client secret that starts with `pa_`. ```typescript title="lib/server-analytics.ts" import { createServerAnalytics } from 'trakoo/server'; import { PirschServerProvider } from 'trakoo/providers/server'; +import { appEvents } from '@/lib/events'; export const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new PirschServerProvider({ hostname: 'example.com', @@ -75,8 +79,10 @@ Provide both `clientId` and `clientSecret`. ```typescript title="lib/server-analytics.ts" import { createServerAnalytics } from 'trakoo/server'; import { PirschServerProvider } from 'trakoo/providers/server'; +import { appEvents } from '@/lib/events'; export const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new PirschServerProvider({ hostname: 'example.com', diff --git a/www/content/docs/providers/posthog.mdx b/www/content/docs/providers/posthog.mdx index 896ffd2..7b17e0f 100644 --- a/www/content/docs/providers/posthog.mdx +++ b/www/content/docs/providers/posthog.mdx @@ -28,8 +28,10 @@ Use `PostHogClientProvider` from `trakoo/providers/client`. It accepts every opt ```typescript title="lib/analytics.ts" import { createClientAnalytics } from 'trakoo/client'; import { PostHogClientProvider } from 'trakoo/providers/client'; +import { appEvents } from '@/lib/events'; export const analytics = createClientAnalytics({ + events: appEvents, providers: [ new PostHogClientProvider({ token: import.meta.env.VITE_POSTHOG_KEY, @@ -46,8 +48,10 @@ Use `PostHogServerProvider` from `trakoo/providers/server`. Server analytics is ```typescript title="lib/server-analytics.ts" import { createServerAnalytics } from 'trakoo/server'; import { PostHogServerProvider } from 'trakoo/providers/server'; +import { appEvents } from '@/lib/events'; export const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new PostHogServerProvider({ apiKey: process.env.POSTHOG_API_KEY!, diff --git a/www/content/docs/providers/proxy.mdx b/www/content/docs/providers/proxy.mdx index ecaa5f8..99e1921 100644 --- a/www/content/docs/providers/proxy.mdx +++ b/www/content/docs/providers/proxy.mdx @@ -27,9 +27,10 @@ Add `ProxyProvider` to your client analytics and point it at the endpoint that w ```typescript title="lib/analytics.ts" import { createClientAnalytics } from 'trakoo/client'; import { ProxyProvider } from 'trakoo/providers/client'; -import type { AppEvents } from './events'; +import { appEvents } from '@/lib/events'; -export const analytics = createClientAnalytics({ +export const analytics = createClientAnalytics({ + events: appEvents, providers: [ new ProxyProvider({ endpoint: '/api/events', @@ -43,7 +44,7 @@ analytics.track('button_clicked', { buttonId: 'signup-cta' }); ## Server-side usage -Your endpoint receives the batched events and replays them through server analytics. You have two options. +Your endpoint receives the batched events and replays them through server analytics. Import the same `appEvents` registry on both sides so the ingesting server applies the same event lookup and validation as the client. Propertyless events stay propertyless during replay: the server recognizes their normalized empty payload and uses the propertyless server call shape without an `undefined` placeholder. You have two options. Let trakoo own the whole route with `createProxyHandler`: @@ -53,8 +54,10 @@ import { EmitKitServerProvider, createProxyHandler } from 'trakoo/providers/server'; +import { appEvents } from '@/lib/events'; const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new EmitKitServerProvider({ apiKey: process.env.EMITKIT_API_KEY!, @@ -74,8 +77,10 @@ import { PirschServerProvider, ingestProxyEvents } from 'trakoo/providers/server'; +import { appEvents } from '@/lib/events'; const serverAnalytics = createServerAnalytics({ + events: appEvents, providers: [ new PirschServerProvider({ hostname: 'example.com', diff --git a/www/content/docs/providers/visitors.mdx b/www/content/docs/providers/visitors.mdx index 23b05fb..777c25a 100644 --- a/www/content/docs/providers/visitors.mdx +++ b/www/content/docs/providers/visitors.mdx @@ -22,6 +22,7 @@ No npm package required. `VisitorsClientProvider` loads the tracking script from ```typescript title="lib/analytics.ts" import { createClientAnalytics } from 'trakoo/client'; import { VisitorsClientProvider } from 'trakoo/providers/client'; +import { appEvents } from '@/lib/events'; export const visitorsProvider = new VisitorsClientProvider({ token: process.env.NEXT_PUBLIC_VISITORS_TOKEN!, @@ -29,6 +30,7 @@ export const visitorsProvider = new VisitorsClientProvider({ }); export const analytics = createClientAnalytics({ + events: appEvents, providers: [visitorsProvider] }); From 9f58865543af78ed67bf5dfa68ba2d8aa33433a9 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 22 Jul 2026 17:56:58 +0200 Subject: [PATCH 15/20] fix: contain hostile schema access --- ...07-22-standard-schema-event-definitions.md | 30 ++++- src/adapters/client/browser-analytics.ts | 41 ++++-- src/adapters/server/server-analytics.ts | 59 +++++++-- src/core/events/schema.ts | 66 ++++++++-- src/core/events/validation.ts | 93 +++++++++----- test/event-validation.test.ts | 120 ++++++++++++++++++ 6 files changed, 333 insertions(+), 76 deletions(-) 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 index 294e909..1fd49b2 100644 --- a/docs/superpowers/plans/2026-07-22-standard-schema-event-definitions.md +++ b/docs/superpowers/plans/2026-07-22-standard-schema-event-definitions.md @@ -1091,13 +1091,29 @@ do not hand-edit generated changelog content before the release is published. - [ ] **Step 5: Run stale API checks** -```bash -rg -n 'CreateEventDefinition|EventCollection|ExtractEventNames|ExtractEventPropertiesFromCollection|EventMapFromCollection|as const satisfies|properties: \{\} as|create(Client|Server)Analytics<' src test readme.md www/content -rg -n 'getAnalytics\(|resetAnalyticsInstance|createAnalytics as|createClientAnalytics as createAnalytics' src test readme.md www/content -``` - -Expected: no output and exit code 1 from both commands. Historical design/plan -documents are intentionally outside the scan. +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** diff --git a/src/adapters/client/browser-analytics.ts b/src/adapters/client/browser-analytics.ts index a22144c..1ad4fb8 100644 --- a/src/adapters/client/browser-analytics.ts +++ b/src/adapters/client/browser-analytics.ts @@ -63,24 +63,28 @@ 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' } * } * }); * @@ -272,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 @@ -372,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: {...} } * ``` * @@ -458,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: {...} } * ``` * @@ -480,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 @@ -490,8 +505,8 @@ 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); * } * ``` @@ -772,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() * }); @@ -808,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'; * ``` diff --git a/src/adapters/server/server-analytics.ts b/src/adapters/server/server-analytics.ts index b61edb1..8927017 100644 --- a/src/adapters/server/server-analytics.ts +++ b/src/adapters/server/server-analytics.ts @@ -98,24 +98,28 @@ 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' } * } * }); * @@ -302,7 +306,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(); @@ -313,9 +327,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', { @@ -521,8 +546,8 @@ 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); * } * ``` @@ -817,9 +842,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 { diff --git a/src/core/events/schema.ts b/src/core/events/schema.ts index ef5bf04..a867e79 100644 --- a/src/core/events/schema.ts +++ b/src/core/events/schema.ts @@ -51,28 +51,68 @@ export type EventProperties = | 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 { + if (typeof value !== "object" || value === null) { + return { kind: "invalid" }; + } + + try { + if ("kind" in value) { + if (value.kind === "type") return { kind: "type" }; + if (value.kind === "none") return { kind: "none" }; + } + + if (!("~standard" in value)) return { kind: "invalid" }; + const standard = value["~standard"]; + if ( + typeof standard !== "object" || + standard === null || + !("validate" in standard) + ) { + return { kind: "invalid" }; + } + + const validate = standard.validate as StandardSchemaV1.Props< + object, + object + >["validate"]; + if (typeof validate !== "function") return { kind: "invalid" }; + return { + kind: "schema", + standard: standard as StandardSchemaV1.Props, + validate, + }; + } catch { + return { kind: "access_failure" }; + } +} + export function isTypeMarker(value: unknown): value is TypeMarker { - return typeof value === "object" && value !== null && "kind" in value && value.kind === "type"; + return classifyEventProperties(value).kind === "type"; } export function isNoPropertiesMarker( value: unknown, ): value is NoPropertiesMarker { - return typeof value === "object" && value !== null && "kind" in value && value.kind === "none"; + return classifyEventProperties(value).kind === "none"; } export function isStandardSchema( value: unknown, ): value is StandardSchemaV1 { - if (typeof value !== "object" || value === null || !("~standard" in value)) { - return false; - } - - const standard = value["~standard"]; - return ( - typeof standard === "object" && - standard !== null && - "validate" in standard && - typeof standard.validate === "function" - ); + return classifyEventProperties(value).kind === "schema"; } diff --git a/src/core/events/validation.ts b/src/core/events/validation.ts index 77f924a..9941967 100644 --- a/src/core/events/validation.ts +++ b/src/core/events/validation.ts @@ -7,9 +7,7 @@ import { type EventRegistry, } from "./registry.js"; import { - isNoPropertiesMarker, - isStandardSchema, - isTypeMarker, + classifyEventProperties, } from "./schema.js"; import type { EventCategory } from "./types.js"; @@ -137,7 +135,28 @@ export async function resolveEvent< ); } - if (isNoPropertiesMarker(definition.properties)) { + let category: EventCategory; + let properties: ReturnType; + try { + category = definition.category; + properties = classifyEventProperties(definition.properties); + } catch { + return applyValidationFailurePolicy( + new AnalyticsValidationError("validator_failure", eventName), + validation, + debug, + ); + } + + if (properties.kind === "access_failure") { + return applyValidationFailurePolicy( + new AnalyticsValidationError("validator_failure", eventName), + validation, + debug, + ); + } + + if (properties.kind === "none") { if (inputProvided) { return applyValidationFailurePolicy( new AnalyticsValidationError("invalid_properties", eventName), @@ -147,12 +166,12 @@ export async function resolveEvent< } return { name: eventName, - category: definition.category, + category, properties: {} as EventOutputMap[N], }; } - if (isTypeMarker(definition.properties)) { + if (properties.kind === "type") { if (!isPropertyObject(input)) { return applyValidationFailurePolicy( new AnalyticsValidationError("invalid_properties", eventName), @@ -162,12 +181,12 @@ export async function resolveEvent< } return { name: eventName, - category: definition.category, + category, properties: input as EventOutputMap[N], }; } - if (!isStandardSchema(definition.properties)) { + if (properties.kind === "invalid") { return applyValidationFailurePolicy( new AnalyticsValidationError("invalid_properties", eventName), validation, @@ -177,7 +196,7 @@ export async function resolveEvent< let result: StandardSchemaV1.Result; try { - result = await definition.properties["~standard"].validate(input); + result = await properties.validate.call(properties.standard, input); } catch { return applyValidationFailurePolicy( new AnalyticsValidationError("validator_failure", eventName), @@ -186,37 +205,43 @@ export async function resolveEvent< ); } + let failure: AnalyticsValidationError | undefined; + let output: object | undefined; try { - if ("issues" in result && result.issues) { - return applyValidationFailurePolicy( - new AnalyticsValidationError( + if ("issues" in result) { + const issues = result.issues; + if (issues) { + failure = new AnalyticsValidationError( "invalid_properties", eventName, - normalizeIssues(result.issues), - ), - validation, - debug, - ); + normalizeIssues(issues), + ); + } } - if (!("value" in result) || !isPropertyObject(result.value)) { - return applyValidationFailurePolicy( - new AnalyticsValidationError("invalid_output", eventName), - validation, - debug, - ); + 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); + } + } } - - return { - name: eventName, - category: definition.category, - properties: result.value as EventOutputMap[N], - }; } catch { - return applyValidationFailurePolicy( - new AnalyticsValidationError("validator_failure", eventName), - validation, - debug, - ); + failure = new AnalyticsValidationError("validator_failure", eventName); + } + + if (failure) { + return applyValidationFailurePolicy(failure, validation, debug); } + + return { + name: eventName, + category, + properties: output as EventOutputMap[N], + }; } diff --git a/test/event-validation.test.ts b/test/event-validation.test.ts index 55ba867..521223a 100644 --- a/test/event-validation.test.ts +++ b/test/event-validation.test.ts @@ -6,6 +6,9 @@ import { resolveEvent, typed, type EventName, + isNoPropertiesMarker, + isStandardSchema, + isTypeMarker, } from "@/core/events"; import { describe, expect, expectTypeOf, it, vi } from "vitest"; @@ -31,6 +34,51 @@ function invalidResult( 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; + + return [ + ["throwing ~standard getter", throwingStandardGetter], + ["throwing validate getter", throwingValidateGetter], + ["throwing Proxy has trap", throwingHasTrap], + ["throwing Proxy get trap", throwingGetTrap], + ]; +} + const events = defineEvents({ purchaseCompleted: { name: "purchase_completed", @@ -110,6 +158,78 @@ async function rejectedValidationError( } describe("resolveEvent", () => { + 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, From 831ba6bbef1ae2c2a10cb329370bb68d5b8bd907 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Thu, 23 Jul 2026 07:17:45 +0200 Subject: [PATCH 16/20] fix: prefer standard schema validators --- src/core/events/schema.ts | 42 ++++++++++++++++++----------------- test/event-validation.test.ts | 38 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/core/events/schema.ts b/src/core/events/schema.ts index a867e79..990f226 100644 --- a/src/core/events/schema.ts +++ b/src/core/events/schema.ts @@ -71,31 +71,33 @@ export function classifyEventProperties( } 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 ("kind" in value) { if (value.kind === "type") return { kind: "type" }; if (value.kind === "none") return { kind: "none" }; } - if (!("~standard" in value)) return { kind: "invalid" }; - const standard = value["~standard"]; - if ( - typeof standard !== "object" || - standard === null || - !("validate" in standard) - ) { - return { kind: "invalid" }; - } - - const validate = standard.validate as StandardSchemaV1.Props< - object, - object - >["validate"]; - if (typeof validate !== "function") return { kind: "invalid" }; - return { - kind: "schema", - standard: standard as StandardSchemaV1.Props, - validate, - }; + return { kind: "invalid" }; } catch { return { kind: "access_failure" }; } diff --git a/test/event-validation.test.ts b/test/event-validation.test.ts index 521223a..a3a1c76 100644 --- a/test/event-validation.test.ts +++ b/test/event-validation.test.ts @@ -158,6 +158,44 @@ async function rejectedValidationError( } describe("resolveEvent", () => { + 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) => { From c9e5835e85c0a01e0baf1ccce4d6a78f6b9b9d85 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Thu, 23 Jul 2026 07:30:07 +0200 Subject: [PATCH 17/20] fix: support callable schemas and handled tracking --- readme.md | 4 +- src/core/events/schema.ts | 5 +- test/event-validation.test.ts | 92 +++++++++++++++++++ www/content/docs/(Getting Started)/index.mdx | 2 +- .../docs/(Getting Started)/installation.mdx | 2 +- .../docs/(Getting Started)/quick-start.mdx | 38 +++++--- .../docs/core-concepts/client-vs-server.mdx | 4 +- www/content/docs/core-concepts/events.mdx | 4 +- .../docs/core-concepts/identifying-users.mdx | 2 +- www/content/docs/core-concepts/index.mdx | 2 +- www/content/docs/core-concepts/providers.mdx | 6 +- .../docs/core-concepts/type-safety.mdx | 6 +- www/content/docs/guides/nextjs.mdx | 14 ++- .../docs/guides/standard-schema-migration.mdx | 2 +- www/content/docs/guides/sveltekit.mdx | 24 +++-- www/content/docs/providers/emitkit.mdx | 2 +- www/content/docs/providers/index.mdx | 2 +- www/content/docs/providers/proxy.mdx | 2 +- www/content/docs/providers/visitors.mdx | 2 +- 19 files changed, 167 insertions(+), 48 deletions(-) diff --git a/readme.md b/readme.md index ed198f6..3dcdb89 100644 --- a/readme.md +++ b/readme.md @@ -91,13 +91,13 @@ export const analytics = createClientAnalytics({ 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. ```typescript -analytics.track('user_signed_up', { +await analytics.track('user_signed_up', { userId: 'user-123', email: 'ada@example.com', plan: 'pro' }); -analytics.track('session_started'); +await analytics.track('session_started'); ``` The registry drives autocomplete and rejects misspelled names, missing properties, extra properties, and a properties argument for `session_started`. diff --git a/src/core/events/schema.ts b/src/core/events/schema.ts index 990f226..c2adff4 100644 --- a/src/core/events/schema.ts +++ b/src/core/events/schema.ts @@ -66,7 +66,8 @@ export type EventPropertiesClassification = export function classifyEventProperties( value: unknown, ): EventPropertiesClassification { - if (typeof value !== "object" || value === null) { + const isObject = typeof value === "object" && value !== null; + if (!isObject && typeof value !== "function") { return { kind: "invalid" }; } @@ -92,6 +93,8 @@ export function classifyEventProperties( } } + if (!isObject) return { kind: "invalid" }; + if ("kind" in value) { if (value.kind === "type") return { kind: "type" }; if (value.kind === "none") return { kind: "none" }; diff --git a/test/event-validation.test.ts b/test/event-validation.test.ts index a3a1c76..95b0fb4 100644 --- a/test/event-validation.test.ts +++ b/test/event-validation.test.ts @@ -28,6 +28,24 @@ function schema( }; } +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 { @@ -70,12 +88,43 @@ function hostileSchemas(): readonly [string, StandardSchemaV1][] }, }, ) 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], ]; } @@ -158,6 +207,49 @@ async function rejectedValidationError( } 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) => { diff --git a/www/content/docs/(Getting Started)/index.mdx b/www/content/docs/(Getting Started)/index.mdx index 6d34bdc..8833e1e 100644 --- a/www/content/docs/(Getting Started)/index.mdx +++ b/www/content/docs/(Getting Started)/index.mdx @@ -79,7 +79,7 @@ The root `trakoo` import contains shared types and environment-neutral event hel 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 a8e89c7..03bd290 100644 --- a/www/content/docs/(Getting Started)/installation.mdx +++ b/www/content/docs/(Getting Started)/installation.mdx @@ -200,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 117a796..bed1895 100644 --- a/www/content/docs/(Getting Started)/quick-start.mdx +++ b/www/content/docs/(Getting Started)/quick-start.mdx @@ -102,10 +102,14 @@ export function SignupButton() { return (