--- title: Analytics description: How a Luma integration sends analytics events — the _analytics.ts file, its events, triggers and readers, product references, delivery and what each part adds to the page. slug: luma/analytics docKind: reference hub: luigisbox-ai --- Luma sends [analytics events](/analytics/overview/) to Luigi's Box as your integration's `_global/_analytics.ts` describes them. Every event sent is one `collect.` 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. ```ts // 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: 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: ```ts analytics: import.meta.glob('./*/_analytics.ts', { import: 'default' }), ``` `analytics: false` in `_global/_config.ts` turns analytics off whatever the file says. ## A line per event Each line names the event, when it happens and how it is read: ```ts 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](#readers) 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. ## Events | Event | Sent as | What `.read()` returns | |---|---|---| | `pageView` | A page view | The page's reference, or `null` for a page without one. One `pageView` line per file — see [Page views](#page-views). | | `click` | [`interaction`](/analytics/event-reference/#interaction), `click` | A reference, or `{ reference }` | | `addToCart` | [`interaction`](/analytics/event-reference/#interaction), `add_to_cart` | `{ reference, count?, price? }`, or a list of them. `price` is the unit price. | | `purchase` | [`transaction`](/analytics/event-reference/#transaction) | `{ 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](#your-own-search-and-suggestions) | A click or add-to-cart without a valid [reference](#references) is not sent. `currency` defaults to `_global.currency`. ### Page views 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. ```ts 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. ## Triggers | Trigger | When | Context | |---|---|---| | `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](#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. ## Readers | Reader | Reads | |---|---| | `fromLuma.pageView`, `.click`, `.addToCart` | What Luma's own triggers saw — `lumaPages()`, `lumaClicks()`, `lumaCarts()`. Products are referenced by their `@id`. | | `fromGa4.addToCart`, `.purchase`, `.click`, `.pageView`, `.search`, `.autocomplete`, `.recommender` | GA4 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. ## References Events name catalog objects by [reference](/analytics/references/), `/:`. `ref` builds them: ```ts 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. ### References from GA4 events 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: ```ts // 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](/analytics/references/#when-a-reference-does-not-resolve). With the default `@id`, `item_id` must be the product's `@id` value. ### A different reference for one event `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: ```ts 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.(field, value)` names the field for that one reference and leaves the default unchanged. ## Signed-in shoppers ```ts import { user } from '@/analytics'; user.identify(() => window.shop?.customerId ?? null); ``` The function is read on every event and its value sent as [`user_id`](/analytics/event-reference/#shared-envelope). `null` or `''` sends none. ## Add to cart from a tile A button in a Luma product tile adds to your cart when it carries `data-lbx-cart`, its value the quantity (1 when empty): ```ts html`` ``` 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. ## Your own search and suggestions 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. :::note[Not sent where Luma renders the widget] On a page where Luma renders search, autocomplete or that recommender itself, these events are not sent: Luigi's Box already records the results Luma showed. ::: 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: ```ts 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); ``` ## Delivery - **Each event is one request** to [`POST /analytics/v1/events`](/analytics/sending-events/#the-endpoint), with the same browser token and [visitor ID](/luma/visitor-data/#the-visitor-cookie) 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. ## What it adds to the page 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.ts` | Size | |---|---| | None | Nothing loaded | | Page views only | 2.2 kB | | Page views, add-to-carts and purchases from GA4 | 3.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 | ## Limitations - **A search shown in place fires no page view of yours.** `lumaPages()` counts it; your own tags do not. See [`search.inPlaceQuery`](/luma/widgets/search/). - **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. ## See also - [Analytics overview](/analytics/overview/) — what the events are for - [Event reference](/analytics/event-reference/) — the `interaction` and `transaction` fields - [Visitor data](/luma/visitor-data/) — the visitor ID and what Luma stores in the browser