@commercengine/analytics
v0.1.2
Published
Zero-dependency mappers that transform canonical Commerce Engine entities into canonical e-commerce spec analytics events
Maintainers
Readme
@commercengine/analytics
Zero-dependency, fully-typed mappers that turn canonical Commerce Engine entities into events conforming to the Segment / RudderStack E-Commerce spec.
Every mapper is a pure function: canonical CE shape in → a Segment HTTP
Tracking API envelope out ({ type: "track", event, properties, … }).
Modify it, POST it unchanged to a CDP, or dispatch it through an analytics SDK.
With the browser SDKs (Segment analytics.js / RudderStack JS) identity is
ambient — set once via identify(), then auto-attached to every track. So you
pass no context here; just map and send:
import {
toProductViewed,
toCartViewed,
toCheckoutStarted,
toOrderCompleted,
trackArgs,
} from "@commercengine/analytics";
const { data: product } = await sdk.catalog.getProduct(slug);
analytics.track(...trackArgs(toProductViewed(product)));
const { data: cart } = await sdk.cart.getCart();
analytics.track(...trackArgs(toCartViewed(cart)));
analytics.track(...trackArgs(toCheckoutStarted(cart)));
const { data: order } = await sdk.order.getOrder(orderNumber);
analytics.track(...trackArgs(toOrderCompleted(order)));Currency is derived from each entity. Pass a ctx only when you need a mapping
hook (productUrl, brand) or you're sending the envelope yourself (see below).
Sending events
The returned object is the Segment/RudderStack HTTP Tracking API payload, not the argument list of the browser SDKs. Dispatch it the way your target expects:
// 1. Browser SDK (Segment analytics.js / RudderStack JS): method takes
// (name, properties). Identity is ambient (set via identify()), so no ctx.
analytics.track(...trackArgs(toProductViewed(product)));
// 2. Server SDK (@segment/analytics-node, @rudderstack/rudder-sdk-node):
// no ambient session — pass identity via ctx so it lands on the envelope.
const event = toProductViewed(product, { userId });
analytics.track({ userId: event.userId, event: event.event, properties: event.properties });
// 3. Raw HTTP ingestion: POST the envelope unchanged.
await fetch("https://api.segment.io/v1/track", { method: "POST", body: JSON.stringify(event) });
analytics.track(event)(passing the whole envelope to a browser SDK) is wrong — it would be read as the event-name string. UsetrackArgsor destructureevent.event/event.properties.
Design
- Pure & zero-dependency — no network, no auth, no storage. Just transforms.
- Tree-shakeable — import only the mappers you use.
- Canonical types by construction — input types are the generated CE types,
imported type-only from
@commercengine/storefront-sdk. The imports are erased at build (no runtime dependency — the bundle has zero imports), but the public types reference them, so@commercengine/storefront-sdkis a required peer dependency for TypeScript consumers. It's satisfied automatically if you use@commercengine/storefront(which depends on, and re-exports, the SDK); install@commercengine/storefront-sdkdirectly only if you use this package without the umbrella. - Spec types are the public contract — the Segment shapes in
spec/segment-ecommerce.tsare hand-authored because they describe an external spec, not the CE API.
Mapping context
Every mapper accepts an optional MapContext. Its fields fall into two groups:
Mapping hooks — shape the event properties; useful in every setup:
| Field | Purpose |
| ------------ | ---------------------------------------------------------- |
| productUrl | (ref) => string to build a product page URL (url) |
| brand | (ref) => string to resolve a brand (brand) |
| currency | Last-resort currency fallback (normally derived — see below) |
Envelope identity/metadata — userId, anonymousId, timestamp,
messageId, context. These populate the track/identify envelope and matter
only when you send the envelope yourself (server SDK object form, or raw
HTTP). With the browser SDKs they're redundant: identity and context are managed
by the SDK, and trackArgs omits identity regardless. Leave them unset for
browser usage.
Currency is derived, not passed. Mappers read it from the entity —
Product.pricing.currency,Cart.currency,Order.currency. The only exception isProduct Added/Product Removed, which take a bareCartItem(no currency of its own): pass the parent cart via{ cart }and bothcart_idandcurrencyare derived from it.ctx.currencyexists solely as a last-resort fallback and is rarely needed.
Identifying users
toIdentify maps either canonical user shape — the full API User (e.g.
verifyOtp(...).data.user or the profile endpoint) or the trimmed JWT
UserInfo (e.g. sdk.getUserInfo()) — to a Segment identify envelope;
userTraits is the reusable traits bag; identifyArgs adapts to the browser SDK
analytics.identify(userId, traits) call.
import { toIdentify, userTraits, identifyArgs } from "@commercengine/analytics";
const { data } = await sdk.auth.verifyOtp({ otp, otp_token, otp_action });
// Browser SDK — accepts a full `User`…
analytics.identify(...identifyArgs(toIdentify(data.user)));
// …or the trimmed JWT `UserInfo` (its anonymousId is used automatically)
const info = await sdk.getUserInfo();
analytics.identify(...identifyArgs(toIdentify(info)));
// Server SDK / HTTP — the envelope is the identify payload
const event = toIdentify(data.user, ctx);
// { type: "identify", userId, traits: {…} }Standard traits (email, firstName, lastName, name, phone, avatar,
createdAt, company, …) are mapped from the User, plus CE-specific additive
traits: customer_id, customer_group_id, customer_group, country_code,
email_verified, phone_verified.
Anonymous vs identified. CE, Segment, and RudderStack all distinguish a known
userIdfrom a pseudonymousanonymousId.toIdentifydecides purely onis_anonymous/isAnonymous— not login state, since a user can be logged out yet still known. When not anonymous (even if logged out) it setsuserId(and alsoanonymousId, so Segment links prior anonymous activity); when anonymous it leavesuserIdunset and sets onlyanonymousId— the user's CE id is never used asuserId. An explicitctx.userIdoverrides.
Entity mappers
Convenience functions that derive an event directly from a Commerce Engine shape:
| Mapper | Event | Input |
| ----------------------- | ----------------------- | ----------- |
| toProductsSearched | Products Searched | string |
| toProductViewed | Product Viewed | Product |
| toProductClicked | Product Clicked | Product |
| toProductListViewed | Product List Viewed | Product[] |
| toProductListFiltered | Product List Filtered | Product[] |
| toProductAdded | Product Added | CartItem |
| toProductRemoved | Product Removed | CartItem |
| toCartViewed | Cart Viewed | Cart |
| toCheckoutStarted | Checkout Started | Cart |
| toCheckoutStepViewed | Checkout Step Viewed | Cart |
| toCheckoutStepCompleted | Checkout Step Completed | Cart |
| toPaymentInfoEntered | Payment Info Entered | Cart |
| toOrderCompleted | Order Completed | Order |
| toOrderUpdated | Order Updated | Order |
| toOrderCancelled | Order Cancelled | Order |
| toOrderRefunded | Order Refunded | Order |
Product sources & variant fields
The product mappers (toProductViewed, toProductClicked, toProductListViewed,
toProductListFiltered) and productProperties accept any catalog shape —
Product, ProductDetail, or Item — and normalize the field-name differences
for you. Every emitted product carries the additive variant_id and
variant_slug (beyond the spec's variant name) whenever available, plus the
canonical CE identifiers that are useful for analytics joins: product_slug,
variant_name, product_type, category ids/slugs, tags, stock/promotion/
subscription flags, line-level price/tax/discount fields, and compact product
attributes keyed by CE attribute key/name. Empty slug fields are omitted rather
than emitted as empty strings.
Item, CartItem, and OrderItem already reference a single variant inline. A
Product/ProductDetail describes the parent product (its variants live in
variants[]), so pass the selected one to report variant info:
// Explicit variant (object or id) — 3rd arg; ctx (2nd) is empty here
toProductViewed(product, {}, { variant: selectedVariantId });
toProductClicked(product, {}, { variant: selectedVariant });When no variant is passed, the product's default variant (is_default) is
used if present — including in list events. A resolved variant overrides
variant/variant_id/variant_slug and sku, price, and image_url.
The mappers intentionally do not dump every nested CE object into analytics payloads. Large operational objects like addresses, shipments, raw promotion objects, inventory lots, and seller details are left out by default; add them in your app only when a destination actually needs them.
trackEvent — the rest of the spec, fully typed
Events with no natural source entity (promotions, coupons, checkout steps,
wishlist, sharing, reviews) are covered by a single builder. For spec events the
properties argument is type-checked against the event name; for custom event
names it accepts an open object:
import { trackEvent, trackArgs } from "@commercengine/analytics";
analytics.track(...trackArgs(trackEvent("Promotion Viewed", { promotion_id: "promo_1", name: "Sale" })));
analytics.track(...trackArgs(trackEvent("Coupon Applied", { cart_id, coupon_id, discount: 5 })));Reusable builders & custom events
The property bags are the reusable primitives — productProperties (product),
cartProperties (cart), orderProperties (order). Compose them into events that
aren't in the Segment spec but that you track internally (e.g. Cart Created,
Cart Updated, Order Created, Payment Successful):
cartProperties and orderProperties include the core CE monetary and status
fields: totals/subtotals, tax, shipping, coupon/promotion discount and savings
breakdowns, loyalty/credit amounts, item counts, and cart/order status. They
exclude addresses, metadata, shipments, and raw applied-promotion/coupon objects
by default.
checkoutStepProperties and paymentInfoEnteredProperties derive checkout-step
linking fields from the canonical Cart: checkout_id is the cart id, and
cart_id is emitted separately. shipping_method is derived from
cart.fulfillment_preference (delivery provider/courier, collect-in-store pickup
location, or both for partial fulfillment). Commerce Engine has no separate
checkout id, so order mappers emit the order's cart_id as both cart_id and
the checkout_id proxy.
import { trackEvent, trackArgs, cartProperties, orderProperties } from "@commercengine/analytics";
analytics.track(...trackArgs(trackEvent("Cart Created", cartProperties(cart))));
analytics.track(...trackArgs(trackEvent("Order Created", orderProperties(order))));
analytics.track(...trackArgs(trackEvent("Payment Successful", {
...orderProperties(order),
payment_method: "Visa",
})));To make a custom event strongly typed, augment the spec map via declaration merging:
import type { OrderProperties } from "@commercengine/analytics";
declare module "@commercengine/analytics" {
interface EcommerceEventProperties {
"Cart Created": import("@commercengine/analytics").CartProperties;
"Payment Successful": OrderProperties & { payment_method?: string };
}
}
// Now `trackEvent("Payment Successful", …)` is type-checked.This means every V2 spec event is representable — entity mappers for the
derivable ones, trackEvent for the rest — and your own non-spec events reuse
the exact same builders.
