Event Contracts

Prev Next

# 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 deprecated
detectCustomerKeywordTypeForAnalytics Free-text input to CustomerSupportKeywordType

Adding a New Event

To an existing category

  1. Define the payload type in src/types/analytics/{category}/index.ts:

    export type MyNewEventPayload = {
      some_field: string;
      count: number;
    };
    
  2. Add to the event union in the same file:

    export type MyDomainEvents = ... | 'my_new_event';
    
  3. Add to the payload map:

    export type MyDomainEventPayloadMap = {
      ...
      my_new_event: MyNewEventPayload;
    };
    
  4. 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

  1. Create src/types/analytics/{category}/index.ts with events, payloads, and payload map
  2. Add the category to AnalyticsCategory union in src/types/analytics/index.ts
  3. Add the mapping in CategoryEventMap
  4. Add the payload map to EventPayloadRegistry
  5. 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)