@commercengine/analytics
v0.2.0
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. Initialize the mapping context once, then map and send without passing context at individual event call sites:
import {
createAnalytics,
} from "@commercengine/analytics";
// Configure shared context once. All event mappers below inherit it.
const events = createAnalytics({ storeId });
const { data: product } = await sdk.catalog.getProduct(slug);
analytics.track(...events.trackArgs(events.toProductViewed(product)));
const { data: cart } = await sdk.cart.getCart();
analytics.track(...events.trackArgs(events.toCartViewed(cart)));
analytics.track(...events.trackArgs(events.toCheckoutStarted(cart)));
const { data: order } = await sdk.order.getOrder(orderNumber);
analytics.track(...events.trackArgs(events.toOrderCompleted(order)));Currency is derived from each entity. Put mapping hooks (productUrl, brand),
SSR cookie access, consent controls, and envelope identity in the initializer as
needed. Consumers do not need to know which events have server counterparts;
every mapper receives storeId, and only events in the deduplication contract
use it.
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, options). Identity is ambient (set via identify()); the
// third argument carries automatically collected destination identifiers.
analytics.track(...events.trackArgs(events.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 serverEvents = createAnalytics({ storeId, userId });
const event = serverEvents.toProductViewed(product);
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/event.context.
Upgrading from 0.1.2
The recommended API initializes shared context once:
const events = createAnalytics({ storeId });
analytics.track(...events.trackArgs(events.toOrderCompleted(order)));The standalone mappers remain available for compatibility, but the initializer
means consumers never need to know which events have server counterparts or
repeat storeId at individual call sites.
trackArgs now returns three elements: [event, properties, options]. Existing
spread calls therefore need no dispatch change. Code that destructures the tuple
or forwards it through a fixed two-argument facade must accept and pass the third
argument:
// Before
const [name, properties] = trackArgs(event);
analytics.track(name, properties);
// Now
const [name, properties, options] = trackArgs(event);
analytics.track(name, properties, options);Other usage changes:
- Pass
storeIdinMapContextto derive browser/serverevent_idvalues. toOrderRefundedneeds{ requestNumber }to derive an ID for each refund.- Destination identifiers are collected automatically. Use
enrich: falseto disable collection, or per-platform toggles such asenrich: { tiktok: false }. - For SSR, pass a Cookie header/getter through
cookies; browser usage needs no cookie wiring. - For Hosted Checkout or another envelope this package did not build, use
ambientContext()as the browser SDK's third argument. trackArgsWithContextis removed; usetrackArgs.readFbCookiesandwithFbCookiesare replaced byreadAmbientIdentifiersandambientContext.userTraitsnow normalizes phone numbers and omits a national number when no country code is available.
Deduplicating against server-side events
Commerce Engine emits the high-value funnel events server-side as well —
Order Completed fires from the payment callback, which is the only place that
knows the payment settled. Meta and GA4 will count the browser event and its
server counterpart as two conversions unless both carry the same
event_id for the same event name.
Set storeId once on the initializer and IDs are derived automatically wherever
the browser/server contract defines one:
const events = createAnalytics({ storeId });
analytics.track(...events.trackArgs(events.toOrderCompleted(order)));
// properties.event_id === "st_01hx8z9.order_completed.ord-10023"The id is derived from stable business keys, never minted — so the server computes the identical value from its own copy of the event, hours later, with no handshake and nothing to persist or pass between them:
| Event | Keyed on |
| ---------------------- | ------------------------------------- |
| Order Completed | order number |
| Order Cancelled | order number |
| Order Refunded | order number + refund request number¹ |
| Checkout Started | cart id |
| Payment Info Entered | cart id² |
| Product Added | cart id + product id + variant id |
¹ Order Refunded needs requestNumber passed explicitly — an order can be
refunded more than once, and Commerce Engine's Order type does not carry the
refund's request number:
analytics.track(...events.trackArgs(
events.toOrderRefunded(order, { requestNumber }),
));Without it the event is emitted with no event_id and will not deduplicate.
² Payment Info Entered has no server-side counterpart today — Commerce
Engine has no API call corresponding to entering payment details on their own
(they are submitted as part of order creation). The id is still derived so the
contract is in place if that changes; right now it simply has nothing to
deduplicate against.
Without storeId the field is simply omitted and the events go
undeduplicated — nothing breaks, but Meta double-counts. When a key is missing
(a Product Added with no variant_id, say) the id is omitted rather than
partially built: a partial id would be shared by every event hitting the same
gap, and the ad platform would collapse them into a single conversion. No
deduplication is strictly better than false deduplication.
eventId on the mapper options overrides the derivation if you mint ids
elsewhere — but then you own keeping both sides in step.
Why
properties, not the envelope? The envelope'smessageIddoes not survive dispatch through a browser SDK —trackArgsomits identity andmessageIddeliberately.properties.event_idis the channel that survives on both the browser and the server.
Destination identifiers — collected automatically
Every ad platform matches a server-side conversion back to the click that caused it using an identifier its own tag wrote into a first-party cookie, or a click id it appended to the landing URL. Each reads a different key from a different place, and a value in the wrong place is silently ignored.
You wire none of it. Identifiers are collected inside every track mapper and
delivered by trackArgs (identify envelopes do not need destination click
identifiers):
analytics.track(...events.trackArgs(events.toOrderCompleted(order)));
// third argument carries { context: { fbc, fbp, gclid, ttclid, … } }| Platform | Collected |
| --- | --- |
| Meta | fbc, fbp (_fbc / _fbp cookies) |
| Google | gclid, gbraid, wbraid, ga_client_id (_gcl_aw / _ga) |
| TikTok | ttclid, ttp (_ttp) |
| Snapchat | sccid, scid (_scid) |
| Reddit | rdt_cid, rdt_uuid (_rdt_uuid) |
| Microsoft | msclkid |
| LinkedIn | li_fat_id |
| X / Twitter | twclid |
| Pinterest | epik (_epik) |
Anything absent is simply not collected — no placeholders, no empty strings. Adding a platform is a package change, not a change in your app.
Verification status. Meta's
context.fbc/context.fbpare confirmed against RudderStack's Facebook Conversions destination. The rest are collected under each platform's conventional key name; confirm against your destination's field mapping before relying on them. An unread field is harmless, but it is not attribution.
Consent is yours to enforce. Collection is unconditional: click ids are read from the landing URL even when a platform's own tag never ran, so they are available before any consent dialog resolves. If you operate under GDPR, CCPA or similar, gate it with
enrich: falseuntil consent is granted — this package has no notion of consent state and will not infer one.
Google cookie names are the defaults. Only
_gaand_gcl_aware read. A property configured with a custom cookie prefix, or a_ga_<CONTAINER>session cookie, is not collected.
Opting out
createAnalytics({ storeId, enrich: false });
createAnalytics({ storeId, enrich: { tiktok: false } });
createAnalytics({ storeId, context: { fbp: "…" } });For a one-event override, derive a temporary bound client without changing the defaults:
const withoutEnrichment = events.withContext({ enrich: false });
analytics.track(...withoutEnrichment.trackArgs(
withoutEnrichment.toOrderCompleted(order),
));Events this package did not build
Commerce Engine Hosted Checkout runs in a cross-origin iframe, so it cannot read the parent's first-party cookies — and shouldn't. The parent attaches them when forwarding:
import { ambientContext } from "@commercengine/analytics";
export function forwardCheckoutAnalytics(event) {
if (event.type !== "track") return;
analytics.track(event.event, event.properties, ambientContext());
}Server-side dispatch
Collection reads document.cookie and the current URL, so it yields nothing
during SSR — safely, never throwing. To enrich from a request instead, pass a
Cookie header or a getter (a Commerce Engine CookieAdapter's get fits):
{ storeId, cookies: request.headers.get("cookie") ?? undefined }
{ storeId, cookies: (name) => adapter.get(name) }Design
- Zero-dependency — no network, no auth, no storage; nothing is ever written.
- Pure transforms, with one deliberate exception — mappers are pure functions
of their inputs, except that they read ambient destination identifiers
(cookies, URL) so no application has to wire each platform by hand. Disable it
with
enrich: false, or make it pure again by passingcookiesexplicitly. - 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
createAnalytics accepts persistent AnalyticsDefaults; standalone mappers
continue to accept the wider MapContext for backward compatibility.
Mapping hooks — shape the event properties; useful in every setup:
| Field | Purpose |
| ------------ | ---------------------------------------------------------- |
| storeId | Commerce Engine store id — derives event_id for browser↔server deduplication |
| 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) |
storeId is the one field worth setting even in a pure browser setup — without
it, events cannot be deduplicated against their server-side counterparts. See
Deduplicating against server-side events.
Persistent envelope identity — userId and anonymousId. These matter only
when you send envelopes yourself (server SDK object form or raw HTTP). Browser
SDK identity is ambient, and trackArgs omits it regardless.
Per-event metadata — timestamp and messageId are intentionally rejected
by createAnalytics and withContext. Reusing a messageId would make a CDP
treat distinct events as duplicates. Attach either value to one completed
envelope instead:
const event = events.withEventMetadata(
events.toOrderCompleted(order),
{ messageId: crypto.randomUUID(), timestamp: new Date().toISOString() },
);context is different: it is where destinations read their identifiers, so it
is delivered by trackArgs as the third argument, and it is populated
automatically — see
Destination identifiers.
withContext recursively merges ordinary nested context objects; explicit
values win, while undefined leaves the initializer value unchanged.
messageIddoes not survivetrackArgs— it is deliberately dropped along with identity. Anything a destination must see from the browser travels inproperties(asevent_iddoes) or incontext.
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.
const { data } = await sdk.auth.verifyOtp({ otp, otp_token, otp_action });
// Browser SDK — accepts a full `User`…
analytics.identify(...events.identifyArgs(events.toIdentify(data.user)));
// …or the trimmed JWT `UserInfo` (its anonymousId is used automatically)
const info = await sdk.getUserInfo();
analytics.identify(...events.identifyArgs(events.toIdentify(info)));
// Server SDK / HTTP — the envelope is the identify payload
const event = events.toIdentify(data.user);
// { 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.
phoneare normalized. Destinations hash these verbatim to build match keys, sophoneis reduced to digits including country code (919897622832). CE stores the subscriber number and country code separately, so a value starting with+is treated as already international and anything else getscountry_codeprepended.A phone with no resolvable country code is dropped, not guessed. A national number without its prefix hashes to something no ad platform will ever match, which dilutes match quality rather than merely failing. The server applies the identical rule, so both sides produce the same hash for the same user.
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 — events.productProperties
(product), events.cartProperties (cart), events.orderProperties (order).
The bound versions inherit the initializer's currency, URL, and brand hooks.
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.
analytics.track(...events.trackArgs(events.trackEvent("Cart Created", events.cartProperties(cart))));
analytics.track(...events.trackArgs(events.trackEvent("Order Created", events.orderProperties(order))));
analytics.track(...events.trackArgs(events.trackEvent("Payment Successful", {
...events.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.
