Events
Define a typed event registry, optionally validate at runtime, and control validation failures.
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
import { defineEvents, noProperties, typed } from 'trakoo';
export const appEvents = defineEvents({
userSignedUp: {
name: 'user_signed_up',
category: 'user',
properties: typed<{
email: string;
plan: 'free' | 'pro' | 'enterprise';
referralSource?: string;
}>()
},
buttonClicked: {
name: 'button_clicked',
category: 'engagement',
properties: typed<{
buttonId: string;
location: string;
}>()
},
sessionStarted: {
name: 'session_started',
category: 'user',
properties: noProperties()
}
});
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
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 definition requires a category. trakoo autocompletes common categories:
usernavigationconversionengagementerrorperformance
Custom strings such as billing, product, and ai are also accepted.
Validator-free properties
typed<T>() is the primary developer experience. It declares the input and provider-output type without installing or executing a validator:
properties: typed<{
plan: 'free' | 'pro' | 'enterprise';
amount: number;
currency: 'USD' | 'EUR' | 'GBP';
}>()
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.
typed<T>() accepts object shapes, including nested objects and array-valued fields, but the top-level event properties must be an object.
Propertyless events
Use noProperties() when an event carries no custom properties:
const lifecycleEvents = defineEvents({
sessionStarted: {
name: 'session_started',
category: 'user',
properties: noProperties()
}
});
await analytics.track('session_started');
Passing {} or any other second argument is invalid. Providers receive an empty properties object after trakoo resolves the event.
Runtime validation
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:
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()
})
}
});
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.
Other Standard Schema-compatible libraries work the same way; trakoo does not wrap or adapt them.
Validation failure policy
Unknown wire names, missing or unexpected properties arguments, schema issues, validator exceptions, and non-object validator outputs are validation failures. On the server, a malformed options argument — a third track() argument containing a key outside userId, sessionId, context, and user — is also a validation failure, reported with the invalid_options code.
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:
import { createClientAnalytics } from 'trakoo/client';
import { commerceEvents } from './commerce-events';
const analytics = createClientAnalytics({
events: commerceEvents,
providers: [/* ... */],
validation: {
onFailure: 'throw',
onError: (error) => reportValidationFailure(error)
}
});
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.
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.
The validation policy applies only to event registry and property validation. Initialization failures and provider failures retain their existing behavior.
Async schemas and ordering
Validators may return promises. Concurrent calls proceed as their validators finish, so invocation order does not guarantee provider delivery order:
await Promise.all([
analytics.track('first_event', firstInput),
analytics.track('second_event', secondInput)
]);
Await calls one at a time if order matters:
await analytics.track('first_event', firstInput);
await analytics.track('second_event', secondInput);
Tracking
Create a registry-bound instance; no event generic is needed:
import { createClientAnalytics } from 'trakoo/client';
import { appEvents } from './events';
export const analytics = createClientAnalytics({
events: appEvents,
providers: [/* ... */]
});
await analytics.track('button_clicked', {
buttonId: 'cta',
location: 'hero'
});
On the server, pass user context after properties and shut down before a short-lived runtime exits:
await serverAnalytics.track('user_signed_up', {
email: 'ada@example.com',
plan: 'pro'
}, {
userId: 'user_123'
});
await serverAnalytics.shutdown();
Organizing registries
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.
import { defineEvents } from 'trakoo';
import { productEvents } from './product';
import { userEvents } from './user';
export const appEvents = defineEvents({
productViewed: productEvents.productViewed,
userSignedUp: userEvents.userSignedUp
});
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.
