Skip to content

    Pre-launch API. The concepts described here are settled, but the API shape is not: request and response fields, parameters and defaults can still change. Build against it, and talk to your Luigi's Box contact before you put an integration into production.

    Analytics

    View source

    Luma sends analytics events to Luigi’s Box as your integration’s _global/_analytics.ts describes them. Every event sent is one collect.<event> line in that file, and nothing else is sent: the file read top to bottom is what your site collects. Without the file there is no analytics — no code is loaded and nothing is sent.

    integration/_global/_analytics.ts
    import { collect, dl, fromGa4, fromLuma, lumaCarts, lumaClicks, lumaPages, ref } from '@/analytics';
    ref.product.by('sku'); // product references by the catalog's `sku` field: product/sku:<value>
    collect.pageView.on(lumaPages()).read(fromLuma.pageView);
    collect.click.on(lumaClicks()).read(fromLuma.click);
    collect.addToCart.on(lumaCarts()).read(fromLuma.addToCart);
    collect.addToCart.on(dl('add_to_cart')).read(fromGa4.addToCart);
    collect.purchase.on(dl('purchase')).read(fromGa4.purchase);

    __integration.ts hands the file to Luma as a lazy glob:

    analytics: import.meta.glob('./*/_analytics.ts', { import: 'default' }),

    analytics: false in _global/_config.ts turns analytics off whatever the file says.

    Each line names the event, when it happens and how it is read:

    collect.addToCart // the event sent: an add-to-cart
    .on(dl('add_to_cart')) // when: each dataLayer push with event 'add_to_cart'
    .read(fromGa4.addToCart) // how: reads the standard GA4 ecommerce event and remaps it to a Luigi's Box analytics event

    .read() takes a reader or a function of the trigger’s context that returns what the event sends; null sends nothing. Before .read():

    .when(f)Only when f(context) is true
    .throttle(ms)At most once per ms; the rest are dropped
    .once()At most once per page

    A file can have any number of lines for the same event, from different triggers.

    EventSent asWhat .read() returns
    pageViewA page viewThe page’s reference, or null for a page without one. One pageView line per file — see Page views.
    clickinteraction, clickA reference, or { reference }
    addToCartinteraction, add_to_cart{ reference, count?, price? }, or a list of them. price is the unit price.
    purchasetransaction{ id?, currency?, items: [{ reference, title, count, total_price, was_discounted? }] }. Sent once per id, so a reloaded thank-you page does not count twice.
    search, autocomplete, recommender(name)The results your own service showed{ query?, items, filters? } — see Your own search and suggestions

    A click or add-to-cart without a valid reference is not sent. currency defaults to _global.currency.

    On lumaPages(), one page view per page, sent at once.

    On any other trigger, each page still sends one page view, and waits for its reference from that trigger — the product of a product page, read from GA4’s view_item. After .timeout(ms) (1000 ms by default) it is sent without one.

    collect.pageView.on(dl('view_item')).timeout(1500).read(fromGa4.pageView);

    A page is a URL without its tracking parameters (gclid, utm_*) and its hash. Filtering, sorting or “show more” on a Luma listing is not a new page.

    TriggerWhenContext
    lumaPages()Each page: the first, the next page of a single-page app, a page restored from the back/forward cache, a prerendered page once it is shown{ url, referrer }
    lumaClicks()A click on a product in a Luma widget: any link to it, middle and modifier clicks included, and a pick in the autocomplete{ reference, result, index, list }
    lumaCarts()A click on a [data-lbx-cart] button in a Luma product tile — see Add to cart from a tile{ reference, result, index, list, count, price? }
    dl(event)A dataLayer push with that event name, in object or gtag() form, including pushes made before Luma loaded{ event, eventType, data, ecommerce }
    domClick(selector)A click or middle click on an element of your page matching selector{ element, event }
    urlChange()A navigation in a single-page app{ url, previousUrl }
    mutationOf(selector, { root? })Your page renders that element, or renders into it; read once the browser is idle{ element }
    xhrMatching(({ url, method }) => …)A response to one of your page’s own requests, XMLHttpRequest or fetch, that the test matches{ url, method, response }
    manual()fire(data) is called: const { trigger, fire } = manual(){ data }

    dl() reads window.dataLayer. _global.dataLayer in _config.ts names another layer.

    ReaderReads
    fromLuma.pageView, .click, .addToCartWhat Luma’s own triggers saw — lumaPages(), lumaClicks(), lumaCarts(). Products are referenced by their @id.
    fromGa4.addToCart, .purchase, .click, .pageView, .search, .autocomplete, .recommenderGA4 ecommerce: add_to_cart, purchase, select_item, view_item, view_item_list

    The GA4 readers take the push’s ecommerce object (or gtag()’s parameters), or the push itself when its items are on it. An item is referenced by ref.product(item_id); an item without item_id is skipped. A purchase gets each line’s total — price × quantity — and was_discounted for a line with a discount. A list reader takes the query from search_term.

    Each reader belongs to one event: collect.purchase.on(…).read(fromGa4.addToCart) is a type error, and throws when the file runs.

    Events name catalog objects by reference, <type>/<field>:<value>. ref builds them:

    ref.product('sku-1001'); // product/@id:product/sku-1001
    ref.product('ean', '5901234123457'); // product/ean:5901234123457
    ref.product.by('sku'); // ref.product(value) uses `sku` from here on
    ref.product('ABC-123'); // product/sku:ABC-123

    ref.variant, ref.category and ref.brand work the same way. The default field is @id. Products in Luma’s own widgets are always referenced by their @id, whatever ref.product.by() says.

    The fromGa4 readers reference each item as ref.product(item_id): the item_id of the GA4 item, as the value of the field set by ref.product.by(). That field must be a catalog attribute holding the same values your dataLayer sends as item_id. If item_id is your SKU, name the attribute your catalog stores the SKU in:

    // dataLayer: { event: 'add_to_cart', ecommerce: { items: [{ item_id: 'ABC-123', … }] } }
    ref.product.by('sku'); // sent as product/sku:ABC-123

    A reference that names no catalog object is recorded and then joins to nothing. With the default @id, item_id must be the product’s @id value.

    ref.product.by() applies to every event. When one event’s item_id holds a different identifier — the product code on view_item and add_to_cart, the variant’s EAN on purchase — read that event with your own function and build its references there:

    ref.product.by('sku'); // view_item, add_to_cart: item_id is the SKU
    // purchase: item_id is the variant's EAN
    collect.purchase.on(dl('purchase')).read(({ ecommerce }) => ({
    id: ecommerce?.transaction_id,
    currency: ecommerce?.currency,
    items: (ecommerce?.items ?? []).map((i) => ({
    reference: ref.variant('ean', i.item_id),
    title: i.item_name,
    count: i.quantity ?? 1,
    total_price: (i.price ?? 0) * (i.quantity ?? 1),
    })),
    }));

    ref.<type>(field, value) names the field for that one reference and leaves the default unchanged.

    import { user } from '@/analytics';
    user.identify(() => window.shop?.customerId ?? null);

    The function is read on every event and its value sent as user_id. null or '' sends none.

    A button in a Luma product tile adds to your cart when it carries data-lbx-cart, its value the quantity (1 when empty):

    html`<button type="button" data-lbx-cart="1">Add to cart</button>`

    A click on it calls _global.addToCart(result, { count, el, event }), which you set in _global/_init.ts or _global/_widget.ts to put the product in your cart. It works with or without analytics. With lumaCarts() in _analytics.ts, the click is also an add-to-cart event.

    When your cart then pushes its own add_to_cart to the dataLayer, within 10 seconds of the click, that push is not counted a second time.

    Where your site still renders its own search results, suggestions or recommendations, send what they showed with collect.search, collect.autocomplete and collect.recommender(name), so clicks on them are attributed.

    Each item is { title, type, reference, position }, with price when you have it. A fromGa4 reader on view_item_list builds them, or read your page:

    import { collect, dl, domClick, fromGa4, mutationOf, ref, xhrMatching } from '@/analytics';
    // Your search results, rendered into #results
    collect.search.on(mutationOf('#results')).once().read(({ element }) => ({
    query: { string: new URLSearchParams(location.search).get('q') ?? '' },
    items: [...element.querySelectorAll('[data-sku]')].map((tile, n) => ({
    title: tile.textContent ?? '',
    type: 'product',
    reference: ref.product(tile.getAttribute('data-sku')!),
    position: n + 1,
    })),
    }));
    // Clicks on those results
    collect.click
    .on(domClick('#results [data-sku] a'))
    .read(({ element }) => ref.product(element.closest('[data-sku]')!.getAttribute('data-sku')!));
    // Your suggestions, from your own API
    collect.autocomplete.on(xhrMatching(({ url }) => url.includes('/suggest'))).read(({ response }) => ({
    items: [], // read from response
    }));
    // A recommender, from GA4
    collect.recommender('similar').on(dl('view_item_list')).read(fromGa4.recommender);
    • Each event is one request to POST /analytics/v1/events, with the same browser token and visitor ID as Luma’s discovery requests. A failed request is retried once, with the same event_id.
    • Nothing is lost when the page changes. A click or add-to-cart on a Luma product is stored the moment it happens, and dataLayer pushes on a page the shopper leaves before analytics has loaded — a thank-you page closed at once — are kept. The next page in the same tab sends them, dated as they happened. A purchase is still sent once.
    • Both depend on browser storage. Pending events are kept in sessionStorage, so they are lost when the tab closes, or when storage is unavailable — a private window, a full quota. Sent purchase IDs are kept in localStorage; without it, a reloaded thank-you page sends its purchase again.
    • Nothing speculative counts. A prerendered page sends its events only once it is shown.

    Analytics loads as one file after the page has loaded, holding only what _analytics.ts uses: every event, trigger and reader is separate, so fromGa4.purchase brings that reader alone. Compressed sizes:

    _analytics.tsSize
    NoneNothing loaded
    Page views only2.2 kB
    Page views, add-to-carts and purchases from GA43.6 kB
    + Luma’s own clicks and add-to-carts (lumaClicks(), lumaCarts())+0.2 kB
    + domClick()+0.2 kB
    + a fromGa4 list reader (.search, .autocomplete, .recommender)+0.1 kB
    + mutationOf(), xhrMatching()+0.3 kB, +0.4 kB
    + user.identify(), a page reference, a list read by your own function, .once(), .throttle()Under 0.1 kB each
    • A search shown in place fires no page view of yours. lumaPages() counts it; your own tags do not. See search.inPlaceQuery.
    • A shopper who leaves before analytics loads sends no page view, and no click on your own elements (domClick()). Clicks and add-to-carts on Luma’s products, and dataLayer pushes, are kept for the next page.