@shopkit/events
v0.13.0
Published
Event-driven analytics system for OpenStore storefronts
Readme
@shopkit/events
Event-driven analytics system for e-commerce storefronts.
Features
- 20 standardized events aligned with GA4 enhanced e-commerce
- Type-safe EventBus with middleware pipeline and ring buffer
- 5-stage middleware — schema validation, deduplication, user enrichment, dev logging, server relay
- Cart tracking at the API layer — a fetch/XHR interceptor catches every cart mutation, including ones made by theme forms and third-party apps
- Automatic page views with page type detection
- Product view tracking with 20s engagement timer
- Affiliate / UTM capture on landing — feeds the user-enricher so every pixel event carries attribution context automatically
- Data mappers for cart, product, and order data (with priceDivisor for paise→rupees conversion)
- sendBeacon batching with fetch keepalive fallback
- PII hashing via Web Crypto API (deferred, non-blocking)
- Performance budget of 3.2ms per event end-to-end
Installation
npm install @shopkit/events
# or
bun add @shopkit/eventsPeer dependencies: react, zod, next (optional), zustand (optional)
Quick Start
1. Add EventProvider in your root layout
// app/layout.tsx
'use client';
import { EventProvider } from '@shopkit/events/react';
import { usePathname, useSearchParams } from 'next/navigation';
function EventProviderWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const searchParams = useSearchParams()?.toString();
return (
<EventProvider pathname={pathname} searchParams={searchParams}>
{children}
</EventProvider>
);
}
export default function RootLayout({ children }) {
return (
<html>
<body>
<EventProviderWrapper>{children}</EventProviderWrapper>
</body>
</html>
);
}2. Wire cart events
AddToCart needs no wiring — EventProvider installs the cart API
interceptor, which observes cart requests directly. Tune or disable it via
config.cartApi:
<EventProvider config={{ cartApi: { priceDivisor: 100 } }}> // default
<EventProvider config={{ cartApi: false }}> // opt outThe onCartEvent wiring below still works and is safe to keep — it defers to
the interceptor when one is installed, so nothing double-counts:
// bootstrap/cart.ts
import { eventBus } from '@shopkit/events';
import { createCartMapper } from '@shopkit/events/mappers';
import { createCartEventHandler } from '@shopkit/events/emitters';
import { configureCart } from '@shopkit/cart';
// priceDivisor: 100 converts paise (35500) to rupees (355.00)
const mapper = createCartMapper('INR', 100);
const handleCartEvent = createCartEventHandler(eventBus, mapper);
configureCart({
// ...existing config...
onCartEvent: handleCartEvent,
});3. Track product views
// components/ProductPage.tsx
import { useProductViewEmitter } from '@shopkit/events/emitters';
import { useEventBus } from '@shopkit/events/react';
function ProductPage({ product }) {
const bus = useEventBus();
const standardItem = {
item_id: product.id,
item_name: product.title,
item_brand: product.vendor,
item_category: product.productType,
price: product.price,
quantity: 1,
currency: 'INR',
};
useProductViewEmitter(bus, standardItem);
return <div>{/* product UI */}</div>;
}4. Emit events manually
import { useTrackEvent } from '@shopkit/events/react';
import { OpenStoreEventType } from '@shopkit/events';
function SearchResults({ term, count }) {
const emit = useTrackEvent();
useEffect(() => {
emit(OpenStoreEventType.SEARCH, {
search_term: term,
results_count: count,
});
}, [term, count]);
return <div>{/* results */}</div>;
}API Reference
Core
eventBus
Singleton EventBus instance. The single point through which all analytics events flow.
| Method | Signature | Description |
|--------|-----------|-------------|
| subscribe | (type: OpenStoreEventType, handler) => Unsubscribe | Subscribe to a specific event type |
| subscribeAll | (handler) => Unsubscribe | Subscribe to all events |
| emit | (type, data, overrides?) => OpenStoreEvent \| null | Emit an event (null if blocked by middleware) |
| use | (middleware: Middleware) => void | Register middleware |
| getEventLog | () => ReadonlyArray<OpenStoreEvent> | Get ring buffer of last 100 events |
| reset | () => void | Clear all state (testing only) |
Event Types
import { OpenStoreEventType } from '@shopkit/events';
// 16 client-side events
OpenStoreEventType.PAGE_VIEW
OpenStoreEventType.VIEW_PRODUCT
OpenStoreEventType.ENGAGE_CONTENT
OpenStoreEventType.ADD_TO_CART
OpenStoreEventType.REMOVE_FROM_CART
OpenStoreEventType.VIEW_CART
OpenStoreEventType.BEGIN_CHECKOUT
OpenStoreEventType.ADD_PAYMENT_INFO
OpenStoreEventType.ADD_SHIPPING_INFO
OpenStoreEventType.PURCHASE
OpenStoreEventType.SEARCH
OpenStoreEventType.ADD_TO_WISHLIST
OpenStoreEventType.KWIKPASS_LOGIN_ATTEMPTED
OpenStoreEventType.KWIKPASS_LOGIN_COMPLETED
OpenStoreEventType.VIEW_PROMO
OpenStoreEventType.AB_TEST_VIEWED
// 4 server-side events
OpenStoreEventType.ORDER_FULFILLED
OpenStoreEventType.ORDER_SHIPPED
OpenStoreEventType.ORDER_DELIVERED
OpenStoreEventType.ORDER_CANCELLEDMiddleware (@shopkit/events/middleware)
| Middleware | Factory | Description |
|-----------|---------|-------------|
| schemaValidatorMiddleware | createSchemaValidatorMiddleware() | Validates payloads against Zod schemas |
| deduplicatorMiddleware | createDeduplicatorMiddleware(windowMs?) | Blocks duplicate events within 2s window |
| userEnricherMiddleware | createUserEnricherMiddleware() | Enriches with cookies, affiliate data, PII hashes |
| devLoggerMiddleware | createDevLoggerMiddleware(options?) | Console logging (dev default, env/console toggle) |
| serverRelayMiddleware | createServerRelayMiddleware(config?) | Batches and sends via sendBeacon |
Dev Logger Config
interface DevLoggerOptions {
enabled?: boolean; // Explicitly enable/disable. Default: auto (dev mode only)
}Logging is active when ANY of these is true:
enabled: true(e.g.,process.env.NEXT_PUBLIC_EVENT_DEBUG === "true")NODE_ENV === "development"(default, unlessenabled: false)window.__shopkit_debug = true(ad-hoc toggle from browser console — works in production)
Server Relay Config
interface ServerRelayConfig {
ingestUrl?: string; // Default: /api/events/ingest
flushIntervalMs?: number; // Default: 5000
maxBatchSize?: number; // Default: 10
}Emitters (@shopkit/events/emitters)
installCartApiInterceptor(bus, mapper, options?)
Patches window.fetch and XMLHttpRequest to emit AddToCart from cart API
traffic. Installed automatically by EventProvider — you only call this
directly outside React. Returns an uninstall function.
Because it observes the API rather than the cart store, it also captures
mutations made by theme forms and third-party apps, which never touch
@shopkit/cart. The request body identifies the variant and quantity; the
response supplies title, price, currency and totals.
interface CartApiInterceptorOptions {
addPattern?: RegExp; // Default: /\/cart\/add(\?|$)/
updatePattern?: RegExp; // Default: /\/cart\/change(\?|$)/
readPattern?: RegExp; // Default: /\/cart(\/get)?(\?|$)/ — seeds the baseline, emits nothing
productIdentifier?: ProductIdentifier;
defaultCurrency?: string; // Default: "INR"
onError?: (error: unknown) => void; // Failures are silent without this
}Removals are a change to quantity 0, so they emit nothing. When a change
response reports items_added, that delta is used directly; otherwise the
increase is derived from the request body against the last known quantities.
value is the value of what the request added — sum(item_price × quantity)
over contents — not the cart total. Price scaling comes from the mapper's
priceDivisor, so pass the same divisor you give createCartMapper.
createCartEventHandler(bus, mapper, options?)
Creates a handler for @shopkit/cart's onCartEvent callback.
interface CartEmitterOptions {
productIdentifier?: ProductIdentifier; // Override bus.productIdentifier
}value is the value of what the event added — item_price × quantity over the
units added, matching num_items and the interceptor above. A quantity change
from 1 to 5 emits num_items: 4 and the value of 4 units, not 5.
The handler normalizes raw cart store items (snake_case fields, price as number) automatically. productIdentifier is read lazily from bus.productIdentifier at event-fire time, so it respects config set by EventProvider even if the cart is bootstrapped first.
Defers to the interceptor. When the cart API interceptor is installed, this
handler emits nothing — the interceptor owns AddToCart and builds the richer
payload. Wiring both is safe and does not double-count.
<PageEmitter bus={bus} pathname={pathname} searchParams={searchParams} />
React component that emits page_view on route changes. Rendered automatically by EventProvider.
useProductViewEmitter(bus, product, options?)
Hook that emits view_product on mount and engage_content after 20s dwell time.
Mappers (@shopkit/events/mappers)
| Mapper | Factory | Description |
|--------|---------|-------------|
| CartMapper | createCartMapper(currency?, priceDivisor?) | CartItem to ProductFields. priceDivisor converts raw price units (e.g. 100 for paise→rupees) |
| ShopifyProductMapper | createShopifyProductMapper(currency?) | Product GraphQL to StandardItem |
| ShopifyOrderMapper | createShopifyOrderMapper(currency?) | Order to PurchasePayload |
Note:
ShopifyCartMapperandcreateShopifyCartMapperare deprecated aliases forCartMapperandcreateCartMapper.
React (@shopkit/events/react)
<EventProvider config? pathname? searchParams?>
Root provider. Initializes EventBus with middleware pipeline and renders PageEmitter.
interface EventProviderConfig {
enableDevTools?: boolean;
ingestUrl?: string;
disablePageView?: boolean;
disableServerRelay?: boolean;
productIdentifier?: ProductIdentifier; // "product_id" | "sku" | "variant_id"
bus?: EventBus; // For testing
}Hooks
| Hook | Return Type | Description |
|------|-------------|-------------|
| useEventBus() | EventBus | Access EventBus from context |
| useTrackEvent() | (type, data) => OpenStoreEvent \| null | Stable emit function |
| useEventSubscribe(type, handler) | void | Subscribe with auto-cleanup |
| useEventSubscribeAll(handler) | void | Subscribe to all events |
| useEventLog() | ReadonlyArray<OpenStoreEvent> | Access event ring buffer |
Schemas (@shopkit/events/schemas)
Zod validation schemas for all 20 events plus shared data shapes.
| Export | Description |
|--------|-------------|
| StandardItemSchema | 15-field GA4-aligned item schema |
| StandardUserDataSchema | PII hashed user data schema |
| StandardCookiesSchema | Platform tracking cookies schema |
| eventPayloadSchemas | Record mapping event type to Zod schema |
| OpenStoreEventEnvelopeSchema | Full event envelope structural schema |
Affiliate (@shopkit/events/affiliate)
Client-side capture of UTM parameters and platform click IDs (gclid, fbclid, msclkid, ttclid, twclid, li_fat_id) from the landing URL. Writes to sessionStorage (default) under the "affiliate_data" key — the same key the built-in userEnricherMiddleware reads from, so every event downstream gets attribution context for free.
| Export | Description |
|--------|-------------|
| <AffiliateTracker autoCapture /> | Drop-in client component; mount once at the layout root |
| getAffiliateParams() | Read the stored snapshot (returns null if nothing captured) |
| useAutoCapture(config?) | Hook variant for capture |
| useAffiliateTracker() | Hook returning { affiliateParams, captureParams, clearParams, refreshParams } |
| captureAffiliateParams(config?) | Imperative capture |
| clearAffiliateParams() | Imperative clear |
| configureAffiliateTracker(config) | Adjust defaults (storage type, attribution model, TTL) |
// app/layout.tsx
import { AffiliateTracker } from '@shopkit/events/affiliate';
<AppShell>
<AffiliateTracker autoCapture />
{children}
</AppShell>Pair with the existing event pipeline — nothing else to wire. To forward captured params to a downstream checkout API, read once via getAffiliateParams() and spread into the request payload.
Sub-path Exports
import { eventBus, OpenStoreEventType } from '@shopkit/events';
import { schemaValidatorMiddleware } from '@shopkit/events/middleware';
import { installCartApiInterceptor } from '@shopkit/events/emitters';
import { createCartMapper } from '@shopkit/events/mappers';
import { EventProvider, useTrackEvent } from '@shopkit/events/react';
import { eventPayloadSchemas } from '@shopkit/events/schemas';
import { AffiliateTracker, getAffiliateParams } from '@shopkit/events/affiliate';License
MIT
