# Event Contracts
Sources:
src/types/analytics/,src/lib/helpers/customer-support/analytics.ts
Every analytics event in ONECMS is a typed envelope. The type system enforces valid category-event combinations at compile time — invalid pairings are type errors, not runtime bugs.
The envelope structure and payload rules are formalized in RFC-001: GA4 Event Contract. Domain-specific contracts are defined in RFC-002 (Authentication) and RFC-003 (Customer Support)
Envelope Structure
type AnalyticsEventEnvelope<C extends AnalyticsCategory, E extends CategoryEventMap[C]> = {
category: C;
event: E;
outcome?: EventOutcome;
} & BaseEventContext &
EventPayloadRegistry[E];
An envelope combines three things:
| Part | Type | Purpose |
|---|---|---|
| Routing | category + event |
Determines the GA4 event name (category_event) |
| Context | BaseEventContext |
Site, siteId, env, userId — injected by caller |
| Payload | EventPayloadRegistry[E] |
Event-specific typed fields |
| Outcome | EventOutcome (optional) |
'success' | 'fail' | 'cancel' — modeled as a dimension, not in the event name |
BaseEventContext
Every event carries:
type BaseEventContext = {
site: string;
siteId: string;
env: 'development' | 'testing' | 'production';
userId?: string;
};
Context is resolved from cookies at the call site (typically CookieManager.get('site'), CookieManager.get('siteId'), etc.). The env field is what gates production-only emission in GA.trackAnalytics().
Categories
type AnalyticsCategory = 'authentication' | 'navigation' | 'content' | 'monetization' | 'appcms' | 'customer_support';
Each category maps to a set of valid events via CategoryEventMap:
type CategoryEventMap = {
authentication: keyof AuthenticationEventPayloadMap;
navigation: keyof NavigationEventPayloadMap;
content: keyof ContentEventPayloadMap | keyof UploadEventPayloadMap;
monetization: keyof MonetizationEventPayloadMap;
appcms: keyof AppCmsEventPayloadMap | keyof UploadEventPayloadMap;
customer_support: keyof CustomerSupportEventPayloadMap;
};
Note: content and appcms both include UploadEventPayloadMap because uploads can originate from either context.
Current Event Registry
Authentication (12 events)
Defined in RFC-002: GA4 Authentication Event Contract. Covers the full auth lifecycle — login, MFA, org selection, password reset, social login, and session expiry.
| Event | Payload Type | Key Fields |
|---|---|---|
login |
LoginPayload |
method, error_code? |
forgot_password_click |
ForgotPasswordClickPayload |
source |
forgot_password |
ForgotPasswordPayload |
channel, error_code? |
reset_password |
ResetPasswordPayload |
token_valid, error_code? |
logout |
LogoutPayload |
reason |
session_expired |
SessionExpiredPayload |
duration_minutes? |
mfa_required |
MFARequiredPayload |
method |
mfa |
MFAPayload |
method, error_code? |
otp_resend |
OTPResendPayload |
method, context |
org_selection_view |
OrgSelectionViewPayload |
tenant_count |
org_selection |
OrgSelectionPayload |
selection_mode, tenant_count? |
social_login_click |
SocialLoginClickPayload |
provider |
social_login |
SocialLoginPayload |
provider, error_code? |
Upload (6 events)
| Event | Payload Type | Key Fields |
|---|---|---|
upload_open |
UploadOpenPayload |
context |
upload_select |
UploadSelectPayload |
context, file_count, total_size_bytes |
upload_start |
UploadStartPayload |
context, file_size_bytes, mime_type |
upload_progress |
UploadProgressPayload |
context, percent_completed |
upload_complete |
UploadCompletePayload |
context, file_size_bytes, duration_ms |
upload_cancel |
UploadCancelPayload |
context, percent_completed? |
Upload context disambiguates where the upload originated:
type UploadContext = 'content' | 'brand' | 'settings' | 'template_builder' | 'other';
Customer Support (16 events)
Defined in RFC-003: GA4 Customer Support Event Contract. Covers search, customer detail exploration, quick actions, and support session lifecycle.
| Event | Payload Type | Key Fields |
|---|---|---|
customer_search |
CustomerSearchPayload |
filter, has_keyword, keyword_type, is_external, result_count? |
customer_search_filter_change |
CustomerSearchFilterChangePayload |
previous_filter, next_filter |
customer_search_result_open |
CustomerSearchResultOpenPayload |
source, session_start_attempted |
customer_search_pagination |
CustomerSearchPaginationPayload |
context, limit, page |
customer_search_external_id_detected |
CustomerSearchExternalIdDetectedPayload |
source, id_format |
customer_detail_view |
CustomerDetailViewPayload |
tab, source, is_external |
customer_detail_tab_change |
CustomerDetailTabChangePayload |
from_tab, to_tab |
customer_quick_action_open |
CustomerQuickActionOpenPayload |
section, action_name |
customer_quick_action_submit |
CustomerQuickActionSubmitPayload |
section, action_name, error_code? |
customer_session_restore |
CustomerSessionRestorePayload |
source |
customer_session_start |
CustomerSessionStartPayload |
source, error_code? |
customer_session_save |
CustomerSessionSavePayload |
source, error_code? |
customer_session_end |
CustomerSessionEndPayload |
source, duration_seconds? |
customer_add_user_open |
CustomerAddUserOpenPayload |
source |
customer_add_user_submit |
CustomerAddUserSubmitPayload |
source, error_code? |
Stub Categories
These categories are typed but have no events defined yet:
| Category | Status |
|---|---|
navigation |
Empty — NavigationEventPayloadMap = {} |
content |
Empty — ContentEventPayloadMap = {} (upload events route through UploadEventPayloadMap) |
monetization |
Empty — MonetizationEventPayloadMap = {} |
appcms |
Empty — AppCmsEventPayloadMap = {} |
AnalyticsEventEnum
AnalyticsEventEnum provides a centralized registry of planned event names, organized by domain. It currently contains ~50 entries across navigation, content discovery, publish lifecycle, live workflows, device builds, branding, and settings — serving as a reference for events that will be wired into typed payload maps as instrumentation is added.
The AllAnalyticsEnvelopes Union
The final union consumed by GA.trackAnalytics():
type AllAnalyticsEnvelopes = {
[C in AnalyticsCategory]: {
[E in CategoryEventMap[C]]: AnalyticsEventEnvelope<C, E>;
}[CategoryEventMap[C]];
}[AnalyticsCategory];
This computed type produces a discriminated union of every valid envelope. TypeScript narrows the type when category and event are specified, enforcing that the rest of the payload matches the expected shape.
Domain-Specific Helpers
For domains with many events, helper modules wrap GA.trackAnalytics() to reduce boilerplate. Customer support has the most complete example:
// src/lib/helpers/customer-support/analytics.ts
// Resolves BaseEventContext from cookies
function resolveBaseContext(context?: Partial<BaseEventContext>): BaseEventContext | null;
// Generic tracker — builds envelope and calls GA.trackAnalytics()
function trackCustomerSupportEvent<E extends CustomerSupportEvent>({
event,
payload,
outcome,
context,
}: CustomerSupportTrackArgs<E>): boolean;
// Convenience wrappers
function trackCustomerQuickActionOpen(section, actionName, options?);
function trackCustomerQuickActionSubmit(section, actionName, options?);
function trackCustomerAddUserOpen(options?);
function trackCustomerAddUserSubmit(options?);
// Factory — creates a tracker with pre-bound context
function createCustomerSupportAnalytics(contextProvider?);
Helpers also contain analytics mappers — functions that convert UI-level values to GA-compatible enum values:
| Helper | Converts |
|---|---|
mapCustomerDetailTabForAnalytics |
Tab string to CustomerDetailTab |
mapCustomerDetailSourceForAnalytics |
Navigation source to 'search_result' | 'session_restore' | 'direct_link' |
mapCustomerSearchFilterForAnalytics |
SearchFilterEnum to CustomerSupportSearchFilter |
detectCustomerKeywordTypeForAnalytics |
Free-text input to CustomerSupportKeywordType |
Adding a New Event
To an existing category
-
Define the payload type in
src/types/analytics/{category}/index.ts:export type MyNewEventPayload = { some_field: string; count: number; }; -
Add to the event union in the same file:
export type MyDomainEvents = ... | 'my_new_event'; -
Add to the payload map:
export type MyDomainEventPayloadMap = { ... my_new_event: MyNewEventPayload; }; -
Emit from domain code:
GA.trackAnalytics({ category: 'content', event: 'my_new_event', some_field: 'value', count: 42, site: CookieManager.get('site'), siteId: CookieManager.get('siteId'), env: process.env.NODE_ENV as BaseEventContext['env'], });
The compiler will reject the call if the payload does not match MyNewEventPayload.
To a new category
- Create
src/types/analytics/{category}/index.tswith events, payloads, and payload map - Add the category to
AnalyticsCategoryunion insrc/types/analytics/index.ts - Add the mapping in
CategoryEventMap - Add the payload map to
EventPayloadRegistry - TypeScript will automatically include the new events in
AllAnalyticsEnvelopes
Outcome Modeling
Outcomes are modeled as an envelope-level dimension, not encoded in event names:
// Correct
GA.trackAnalytics({ category: 'authentication', event: 'login', outcome: 'success', ... });
GA.trackAnalytics({ category: 'authentication', event: 'login', outcome: 'fail', ... });
// Wrong — do not create separate events for outcomes
// 'login_success', 'login_fail'
This keeps the event namespace flat and allows GA4 to filter/segment by outcome as a custom dimension.
Schema Versioning
Every normalized payload includes schema_version (injected by GA.trackAnalytics() from Config.GA.schema_version). This field:
- Travels with every event to GA4
- Allows dashboards to distinguish between incompatible payload shapes
- Should be incremented only for breaking changes (renamed fields, restructured shape)
- Should not be incremented for additive changes (new optional fields, new events)