diff --git a/readme.md b/readme.md index 327d9be..1938707 100644 --- a/readme.md +++ b/readme.md @@ -15,6 +15,16 @@ A highly typed, zero-dependency, provider-agnostic analytics library for TypeScr - 🔌 [Providers](https://stacksee-analytics.vercel.app/docs/providers) - 💡 [Core Concepts](https://stacksee-analytics.vercel.app/docs/core-concepts) +## Agent Skill + +Give your coding agent Trakoo-specific integration guidance for typed events, client/server boundaries, providers, and framework setup: + +```bash +npx skills add multiplehats/trakoo --skill trakoo +``` + +The source is [`skills/trakoo/SKILL.md`](./skills/trakoo/SKILL.md) and follows the portable Agent Skills format used by skills.sh-compatible agents. See the [Agent Skill docs](https://stacksee-analytics.vercel.app/docs/agent-skill) for agent targeting, global installs, and manual setup. + ## Features - 🎯 **Type-safe events**: Define your own strongly typed events with full IntelliSense support diff --git a/skills/trakoo/SKILL.md b/skills/trakoo/SKILL.md new file mode 100644 index 0000000..c309148 --- /dev/null +++ b/skills/trakoo/SKILL.md @@ -0,0 +1,201 @@ +--- +name: trakoo +description: Use when adding, configuring, or troubleshooting Trakoo analytics in TypeScript applications, including typed or validated events, browser or server tracking, PostHog, OpenPanel, Bento, Pirsch, EmitKit, Visitors, Proxy, provider routing, user identification, or framework integration. +--- + +# Trakoo Integration + +Trakoo is a typed, provider-agnostic analytics library. Define one runtime event registry, pass it to every analytics factory, and preserve the browser/server boundary. + +## Inspect the application first + +Detect the package manager, framework, installed `trakoo` version, browser/server entry points, analytics dependencies, and verification commands. Decide whether each event is a browser interaction, an authoritative server outcome, or both. + +Use the consuming project's installed Trakoo declarations as the source of truth. Consult the current documentation at https://trakoo.co. If the installed declarations differ, explain the version mismatch instead of silently upgrading or inventing an API. + +- Read [references/events-and-validation.md](references/events-and-validation.md) when defining events, adding Zod or another Standard Schema validator, configuring validation failures, or using propertyless events and custom traits. +- Read [references/providers.md](references/providers.md) when choosing providers, routing events, using Proxy, or building a custom provider. +- Read only the matching section of [references/frameworks.md](references/frameworks.md) for Next.js, SvelteKit, TanStack Start, Astro, or framework-neutral integration. + +## Integration workflow + +1. Install `trakoo`, the selected providers' optional SDKs, and a validator only when runtime validation is wanted. +2. Define and export one shared registry with `defineEvents()`. +3. Put client and server analytics in separate modules and pass `events: appEvents` to each factory. +4. Track where the event becomes true: interactions in browser handlers; payments, signups, jobs, and other authoritative outcomes on the server. +5. Apply identity, validation, and delivery lifecycle rules. +6. Run the consuming project's formatter, type checker, relevant tests, and production build. + +## Shared event registry + +Root helpers are environment-neutral and safe to import from shared code: + +```ts +import { defineEvents, noProperties, typed } from "trakoo"; + +export const appEvents = defineEvents({ + ctaClicked: { + name: "cta_clicked", + category: "engagement", + properties: typed<{ location: "hero" | "pricing" }>(), + }, + purchaseCompleted: { + name: "purchase_completed", + category: "conversion", + properties: typed<{ + orderId: string; + amount: number; + currency: string; + }>(), + }, + sessionStarted: { + name: "session_started", + category: "user", + properties: noProperties(), + }, +}); +``` + +Registry keys organize application source; each `name` is the stable value emitted to providers. Use a Standard Schema-compatible validator directly for runtime validation and transformation. See the event reference for a complete Zod example. + +## Browser module + +```ts +import { typed } from "trakoo"; +import { createClientAnalytics } from "trakoo/client"; +import { PostHogClientProvider } from "trakoo/providers/client"; +import { appEvents } from "./events"; + +interface BrowserUserTraits { + email: string; + plan: "free" | "pro"; +} + +export const analytics = createClientAnalytics({ + events: appEvents, + userTraits: typed(), + providers: [ + new PostHogClientProvider({ + // Browser-public PostHog project key (phc_...), never a personal API key. + token: import.meta.env.VITE_POSTHOG_PROJECT_KEY, + }), + ], +}); + +const analyticsReady = analytics.initialize(); + +export async function identifyUser(userId: string, traits: BrowserUserTraits) { + await analyticsReady; + analytics.identify(userId, traits); +} +``` + +Every factory call creates a fresh, registry-bound instance owned by the application. Retain its initialization promise and await readiness before the first `identify()` or identity-sensitive event because some providers ignore identity calls before their SDK is ready. Login, signup, and restored-session/bootstrap flows must await the identity helper; call `analytics.reset()` on logout. + +At restored-session bootstrap, identify before enabling identity-sensitive events: + +```ts +const restoredSession = await restoreSession(); + +if (restoredSession.user) { + await identifyUser(restoredSession.user.id, { + email: restoredSession.user.email, + plan: restoredSession.user.plan, + }); +} + +// Enable identity-sensitive events only after this bootstrap completes. +``` + +`track()` returns `Promise`. Await work whose completion matters; use `void analytics.track(...)` for explicitly non-critical browser analytics rather than blocking navigation. + +## Server module and critical event + +```ts +import { typed } from "trakoo"; +import { createServerAnalytics } from "trakoo/server"; +import { PostHogServerProvider } from "trakoo/providers/server"; +import { appEvents } from "./events"; + +interface ServerUserTraits { + plan: "free" | "pro"; +} + +function createRequestAnalytics() { + return createServerAnalytics({ + events: appEvents, + userTraits: typed(), + providers: [ + new PostHogServerProvider({ + // PostHog project capture key (phc_...), not a personal API key. + apiKey: process.env.POSTHOG_PROJECT_KEY!, + }), + ], + }); +} + +export async function trackPurchase(input: { + orderId: string; + amount: number; + currency: string; + userId: string; + email: string; + plan: "free" | "pro"; +}) { + const analytics = createRequestAnalytics(); + try { + await analytics.track( + "purchase_completed", + { + orderId: input.orderId, + amount: input.amount, + currency: input.currency, + }, + { + userId: input.userId, + user: { + email: input.email, + traits: { plan: input.plan }, + }, + }, + ); + } finally { + await analytics.shutdown(); + } +} +``` + +Server analytics is stateless across users: pass user context with each event and await critical events. For a fresh request-scoped provider/analytics pair, shut down that same pair in `finally`. A reusable instance shuts down only at application or process teardown. Some providers flush buffered events during shutdown; others only clear state. Inspect the selected provider's installed implementation before choosing the lifecycle. Use the platform's `waitUntil` only for explicitly non-critical work. + +## Imports and ownership + +| Concern | Correct pattern | +|---|---| +| Event helpers and shared types | `trakoo` (environment-neutral) | +| Browser factory | `trakoo/client` | +| Server factory | `trakoo/server` | +| Browser providers | `trakoo/providers/client` | +| Server providers | `trakoo/providers/server` | +| Browser identity | Await initialization before first identity transition; reset on logout | +| Server identity | Pass request user context on every call | +| Critical server delivery | Await `track()`; shutdown follows provider behavior and instance ownership | + +Never import a server provider, secret, or unprefixed server environment variable into browser code. Do not use a nonexistent `trakoo/providers` aggregate. + +## Verification + +Run the consuming project's type checker and narrowest relevant tests. Run its production build when client/server bundling or environment variables changed. Recheck installed declarations when an import, option, event property, or provider constructor fails. + +**Maintainer release gate:** This skill must not be published until PR #32's migrated implementation lands and representative consumer fixtures are compiled and typechecked against the real built package, not fabricated declarations. Fixtures must cover root helpers and registry; direct Zod transformation and distinct input/output types; Valibot or ArkType; client and server factories sharing `events`; browser identify traits and nested server traits; a propertyless client call; a propertyless server-options call; and validation `"drop"` and `"throw"`. + +## Common mistakes + +- Defining calls before the shared runtime registry. +- Forgetting `events: appEvents` in either factory. +- Repeating event generics instead of allowing registry inference. +- Using `typed()` when untrusted runtime input needs a validator. +- Sharing stateful browser identity logic with stateless server analytics. +- Sending all methods and events to providers with different requirements instead of routing them. +- Installing every optional SDK instead of only the selected provider and validator packages. +- Calling `identify()` before client provider initialization or forgetting logout reset. +- Pairing a reusable server instance with per-event shutdown. diff --git a/skills/trakoo/references/events-and-validation.md b/skills/trakoo/references/events-and-validation.md new file mode 100644 index 0000000..c3b05b3 --- /dev/null +++ b/skills/trakoo/references/events-and-validation.md @@ -0,0 +1,167 @@ +# Events and Validation Reference + +Use one shared runtime registry for browser and server analytics. The object keys organize source code; each definition's `name` is the emitted event name accepted by `track()` and sent to providers. + +## Define events + +Install only the chosen validator when runtime validation is needed. Trakoo does not require one: + +```bash +npm install trakoo zod +``` + +```ts +import { defineEvents, noProperties, typed } from "trakoo"; +import { z } from "zod"; + +export const appEvents = defineEvents({ + buttonClicked: { + name: "button_clicked", + category: "engagement", + properties: typed<{ + buttonId: string; + location?: "hero" | "pricing"; + }>(), + }, + purchaseCompleted: { + name: "purchase_completed", + category: "conversion", + properties: z.object({ + orderId: z.string(), + amount: z.coerce.number().positive(), + currency: z.string().length(3), + }), + }, + sessionStarted: { + name: "session_started", + category: "user", + properties: noProperties(), + }, +}); +``` + +The three property modes are: + +| Definition | Compile-time type | Runtime behavior | +|---|---|---| +| `typed()` | Input and output are `T` | No runtime validation | +| Standard Schema validator | Inferred validator input and output | Validates and normalizes before routing | +| `noProperties()` | No properties argument | Normalizes omitted input to an empty provider object | + +Recent compatible Zod, Valibot, ArkType, and other Standard Schema implementations work directly with no Trakoo adapter. Install only the chosen validator; do not add validator-specific Trakoo configuration. Trakoo itself remains validator-optional. + +`typed()` is for trusted, type-checked application values. It does not validate JavaScript, JSON, form data, or other untrusted input at runtime. Use a schema when that boundary needs validation. Duplicate emitted names are a registry initialization error. + +## Schema input and provider output + +A schema can accept one input shape and return a normalized provider output. In the example, Zod coercion accepts a numeric string and providers receive a number: + +```ts +await analytics.track("purchase_completed", { + orderId: "order_456", + amount: "19.95", + currency: "EUR", +}); +``` + +Trakoo awaits the Standard Schema result, rejects invalid or non-object output under the configured policy, and routes the successful output—not the original input—to every selected provider. Transformations, coercion, stripping, and sanitization therefore happen once before routing. + +## Propertyless calls + +Client calls omit argument two: + +```ts +await analytics.track("session_started"); +``` + +For server analytics, server options go directly in argument two: + +```ts +await serverAnalytics.track( + "session_started", + { + userId: "user_123", + user: { + email: "ada@example.com", + traits: { plan: "pro" }, + }, + }, +); +``` + +`userId` belongs directly in server track options. Identity fields such as `email` stay top-level in `user`, while application traits belong under `user.traits`. Do not pass a raw custom-traits object as `user`. + +Never pass an `undefined` properties placeholder. A client second argument, a server `undefined` placeholder, or properties supplied through untyped JavaScript is `invalid_properties` under the validation-failure policy. + +## Factory inference and user traits + +Both factories require the same registry value and infer the event map: + +```ts +import { typed } from "trakoo"; +import { createClientAnalytics } from "trakoo/client"; +import { createServerAnalytics } from "trakoo/server"; +import { appEvents } from "./events"; + +interface BrowserUserTraits { + email: string; + plan: "free" | "pro"; +} + +interface ServerUserTraits { + plan: "free" | "pro"; +} + +const analytics = createClientAnalytics({ + events: appEvents, + userTraits: typed(), + providers: clientProviders, +}); + +const serverAnalytics = createServerAnalytics({ + events: appEvents, + userTraits: typed(), + providers: serverProviders, +}); +``` + +Use separate browser and server trait contracts. The browser marker must declare every object-literal field sent to `identify()`, including canonical identity values such as `email` and application values such as `plan`. The server marker describes only the custom values nested under `user.traits`; keep identity fields such as `email` at the top level of `user`. The optional `userTraits` marker is type-only and is neither validated nor sent to providers as configuration. + +## Validation timing + +Every `track()` variant returns `Promise`. Validation finishes before provider routing begins, so a failed event is never partially delivered. Successful normalized output is shared by every selected provider. + +Standard Schema validation may be asynchronous. Concurrent calls are not guaranteed to reach providers in call order; await calls sequentially when order matters. Provider delivery remains parallel and isolated after validation. + +## Failure policy and security + +`onFailure` defaults to `"drop"` on both client and server. Invalid events resolve without delivery so analytics validation does not fail a successful business operation. Strict tests or workflows can opt into rejection: + +```ts +const analytics = createServerAnalytics({ + events: appEvents, + providers: serverProviders, + validation: { + onFailure: "throw", + onError(error) { + reportValidationFailure({ + code: error.code, + eventName: error.eventName, + paths: error.issues.map((issue) => issue.path), + }); + }, + }, +}); +``` + +`AnalyticsValidationError` reports the emitted event name, normalized issue information, and one of `unknown_event`, `invalid_properties`, `validator_failure`, or `invalid_output`. It never retains the submitted properties object or validator exception. Its normalized issues may include validator-provided messages, and those messages can contain application values. + +When configured, `onError` receives the error exactly once. A throwing or rejected callback cannot change the selected drop/throw policy. Select and sanitize fields before forwarding a validation failure to application observability; the example sends only the code, event name, and normalized paths rather than the whole error. + +Default debug logging exposes only sanitized metadata such as the code, event name, and normalized paths. It never logs raw messages or input. + +This validation policy does not redefine provider-delivery or initialization errors. Those retain their existing behavior, so `track()` can still reject for reasons outside event validation. + +## Verification + +Run the application's type checker and production build. Add runtime tests for schema success, transformation, and rejection. Confirm invalid events do not reach any provider and normalized output reaches every routed provider. diff --git a/skills/trakoo/references/frameworks.md b/skills/trakoo/references/frameworks.md new file mode 100644 index 0000000..1eed199 --- /dev/null +++ b/skills/trakoo/references/frameworks.md @@ -0,0 +1,210 @@ +# Framework Integration Reference + +Read only the section matching the consuming project. Framework APIs evolve; confirm routing hooks and server boundaries against the installed framework version before editing. + +## Detection + +| Signal | Framework | +|---|---| +| `next.config.*` plus `next` dependency | Next.js | +| `svelte.config.*` plus `@sveltejs/kit` | SvelteKit | +| `@tanstack/react-start` and its Vite/Rsbuild plugin | TanStack Start | +| `astro.config.*` plus `astro` | Astro | +| Browser entry plus server/API entry without the above | Framework-neutral TypeScript | + +## Shared rules + +- Keep `events.ts` environment-neutral and import only root event helpers from `trakoo`. +- Import the same registry value in client and server modules: + +```ts +import { appEvents } from "./events"; + +const analytics = createClientAnalytics({ + events: appEvents, + providers: clientProviders, +}); + +const serverAnalytics = createServerAnalytics({ + events: appEvents, + providers: serverProviders, +}); +``` + +- Put `createClientAnalytics` and browser providers in a browser-only module. +- Put `createServerAnalytics`, server providers, and secrets in a server-only module. +- Track a business event where it becomes authoritative, not merely where navigation happens. +- Use the framework's public-variable convention only for browser-safe provider identifiers. + +## Next.js + +Use a client component for browser initialization, identity lifecycle, and navigation effects. Import `appEvents` and pass it as the factory's `events` option. Browser values use `NEXT_PUBLIC_*`; unprefixed values remain server-only. Put critical tracking in route handlers, server actions, or other server-only modules and pass the same registry to the server factory. Await critical events. Call `shutdown()` in the request's `finally` block only when that request created and owns a fresh provider/analytics pair. A reusable module singleton stays alive across requests and shuts down only at application or process teardown. Verify with `next build` because it catches accidental server imports in client bundles. + +## SvelteKit + +Initialize browser analytics only in browser-executed code such as `onMount`; import `appEvents` and pass it to the client factory. Keep server providers in `+page.server.ts`, `+server.ts`, hooks, or other server-only modules and pass the same registry to the server factory. Use `$env/static/public` or `$env/dynamic/public` for client-safe values and private `$env/*/private` modules only on the server. Use the installed SvelteKit navigation API for page views and avoid double-counting the initial navigation. + +## TanStack Start + +Enforce the runtime boundary with filenames such as `analytics.client.ts` and `analytics.server.ts`. As an additional or alternative guard, put the matching side-effect marker at the top of each module: + +```ts +// analytics.client.ts +import "@tanstack/react-start/client-only"; +``` + +```ts +// analytics.server.ts +import "@tanstack/react-start/server-only"; +``` + +Place browser analytics in client-executed application code and server analytics inside `createServerFn` handlers or server routes. Import the shared `appEvents` registry on both sides and pass `events: appEvents` to each factory. Keep `.server.ts` helpers imported only inside a server-function handler or another compiler-recognized server callback: + +```ts +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; + +const trackPurchaseInput = z.object({ + orderId: z.string().min(1), +}); + +export const trackPurchase = createServerFn({ method: "POST" }) + .validator(trackPurchaseInput) + .handler(async ({ data }) => { + const { createRequestAnalytics } = await import("./analytics.server"); + const { requireUser } = await import("./auth.server"); + const { purchases } = await import("./purchases.server"); + + const user = await requireUser(); + const order = await purchases.confirmAndLoad({ + orderId: data.orderId, + userId: user.id, + }); + const projectKey = process.env.POSTHOG_PROJECT_KEY!; + const analytics = createRequestAnalytics(projectKey); + + try { + await analytics.track( + "purchase_completed", + { + orderId: order.id, + amount: order.totalAmount, + currency: order.currency, + }, + { + userId: user.id, + user: { + email: user.email, + traits: { plan: order.plan }, + }, + }, + ); + } finally { + await analytics.shutdown(); + } + }); +``` + +`requireUser` and `purchases.confirmAndLoad` are seams supplied by the consuming application. Authenticate inside the server function, then use the lookup ID plus the authenticated user ID to load and confirm the authoritative purchase. Derive event properties and identity from that server-owned result and session; never accept event properties, user identity, or traits from the browser for an authoritative event. + +With Vite, expose only browser-safe identifiers through `VITE_*`. Read unprefixed deployment environment values inside `.handler()`, middleware `.server()`, server-route handlers, or other per-request callbacks. Edge runtimes may expose deployment bindings differently from Node's `process.env`; use the installed adapter's per-request binding API. + +Choose one page-view owner. The following router-owned approach requires the client `PostHogClientProvider` to use `capture_pageview: false` before the manual initial/navigation stream. If provider auto-capture owns page views, omit this stream. Export the retained client initialization promise as `analyticsReady`, and await it before emitting or recording the initial URL. Normalize initial and router URLs identically so relative and absolute hrefs deduplicate. + +The route-mounted tracker is isomorphic because a root route participates in SSR. It must not be named `*.client.tsx` or carry the client-only side-effect marker. Instead, keep `analytics.client.ts` import-protected and put its dynamic import plus all browser-only setup behind TanStack Start's compiler-recognized `createClientOnlyFn` boundary: + +```tsx +// PageViewTracker.tsx — isomorphic and safe to import from an SSR root route +import { createClientOnlyFn } from "@tanstack/react-start"; +import { useRouter } from "@tanstack/react-router"; +import { useEffect } from "react"; + +const startPageViews = createClientOnlyFn((router: ReturnType) => { + let lastPageUrl: string | undefined; + let unsubscribe: (() => void) | undefined; + let disposed = false; + + async function start() { + const { analytics, analyticsReady } = await import("./analytics.client"); + + function emitPageView(href: string) { + const parsed = new URL(href, window.location.origin); + parsed.hash = ""; + const url = parsed.href; + + if (url === lastPageUrl) return; + lastPageUrl = url; + + analytics.pageView({ + path: `${parsed.pathname}${parsed.search}`, + url, + }); + } + + await analyticsReady; + if (disposed) return; + + emitPageView(window.location.href); + unsubscribe = router.subscribe("onResolved", ({ toLocation }) => { + emitPageView(toLocation.href); + }); + } + + void start(); + + return () => { + disposed = true; + unsubscribe?.(); + }; +}); + +export function PageViewTracker() { + const router = useRouter(); + useEffect(() => startPageViews(router), [router]); + return null; +} +``` + +Mount that isomorphic component once in the long-lived root route: + +```tsx +// routes/__root.tsx +import { Outlet, createRootRoute } from "@tanstack/react-router"; +import { PageViewTracker } from "./PageViewTracker"; + +export const Route = createRootRoute({ component: RootComponent }); + +function RootComponent() { + return ( + <> + + + + ); +} +``` + +The effect cleanup marks a pending initialization as disposed before it can install the subscription, and calls `unsubscribe()` after installation. Apply the same one-owner decision to page-leave tracking; disable provider auto-capture before manually emitting page leaves. Confirm the consuming project's installed TanStack Start version exports `createClientOnlyFn` with this signature, then run its type checker and production build; those checks catch unsupported APIs and import-protection violations. + +## Astro + +Initialize client analytics in a processed `