npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@commercengine/analytics

v0.2.0

Published

Zero-dependency mappers that transform canonical Commerce Engine entities into canonical e-commerce spec analytics events

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. Use trackArgs or destructure event.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 storeId in MapContext to derive browser/server event_id values.
  • toOrderRefunded needs { requestNumber } to derive an ID for each refund.
  • Destination identifiers are collected automatically. Use enrich: false to disable collection, or per-platform toggles such as enrich: { 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.
  • trackArgsWithContext is removed; use trackArgs.
  • readFbCookies and withFbCookies are replaced by readAmbientIdentifiers and ambientContext.
  • userTraits now 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's messageId does not survive dispatch through a browser SDK — trackArgs omits identity and messageId deliberately. properties.event_id is 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.fbp are 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: false until consent is granted — this package has no notion of consent state and will not infer one.

Google cookie names are the defaults. Only _ga and _gcl_aw are 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 passing cookies explicitly.
  • 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-sdk is 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-sdk directly only if you use this package without the umbrella.
  • Spec types are the public contract — the Segment shapes in spec/segment-ecommerce.ts are 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 identityuserId 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 metadatatimestamp 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.

messageId does not survive trackArgs — it is deliberately dropped along with identity. Anything a destination must see from the browser travels in properties (as event_id does) or in context.

Currency is derived, not passed. Mappers read it from the entity — Product.pricing.currency, Cart.currency, Order.currency. The only exception is Product Added/Product Removed, which take a bare CartItem (no currency of its own): pass the parent cart via { cart } and both cart_id and currency are derived from it. ctx.currency exists 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.

email and phone are normalized. Destinations hash these verbatim to build match keys, so email is lowercased and trimmed, and phone is 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 gets country_code prepended.

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 userId from a pseudonymous anonymousId. toIdentify decides purely on is_anonymous/isAnonymousnot login state, since a user can be logged out yet still known. When not anonymous (even if logged out) it sets userId (and also anonymousId, so Segment links prior anonymous activity); when anonymous it leaves userId unset and sets only anonymousId — the user's CE id is never used as userId. An explicit ctx.userId overrides.

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.