Analytics Events

Every user-interaction event EventJuicer records, where it is raised, and which sink it lands in.

Verified against the codebase on 2026-09-16. Each event below names the component that raises it — open that file if you need the exact payload construction.

Two sinks, one bus

Since the first-party journey ledger shipped, a tracked interaction can end up in two places, and the split is not per call site — it is per event name.

┌──────────────────────────────────────┐ component │ useTrackedEvent() ← the only bus │ calls track(...) ───►│ @eventjuicer/analytics/ │ │ use-tracked-event │ └───────────┬──────────────┬───────────┘ │ │ sink 1: first-party ledger │ │ sink 2: GTM / GA4 POST /api/events │ │ dataLayer.push + server action ONLY page_entrance, │ │ EVERY event add_to_cart, form_start ▼ ▼ ShortenerAnalytics window.dataLayer (Neon) + GTM_SERVER_CONTAINER_URL
  • The bus is useTrackedEvent() from @eventjuicer/analytics/use-tracked-event. Every tracked component in the monorepo imports this one, not useGTMTracking. Adding a sink (GA4 direct, PostHog, …) is a change there, never at a call site.
  • useTrackedEvent wraps useGTMTracking() from @eventjuicer/analytics/gtm-track-interaction, which is still the GTM leg. Calling useGTMTracking directly skips the ledger — don't, in new code.
  • SA_EVENT_TYPES in use-tracked-event.ts is the allowlist for sink 1: page_entrance, add_to_cart, form_start. It mirrors TRACKABLE_EVENT_TYPES in @eventjuicer/shortener/event-sink; the two must stay in step or the sink rejects what the bus sends.
  • Both legs are fire-and-forget and individually try/catched. A dead GTM tag must never block a checkout.

POST /api/events — the first-party sink

Mounted per storefront as createEventHandler({ organizerId, app }) from @eventjuicer/shortener/event-sink (see apps/*/app/api/events/route.ts). Anonymous, no auth; organizer + app scope every row. Writes ShortenerAnalytics.

Two params are load-bearing beyond reporting — buildDedupSubject keys de-duplication off them:

eventkeys used for dedup
add_to_cartticket_id, company_id, booth_id, path
form_startform_id, path
page_entranceevent_id (navigation id), path

Panels that must only record an existing campaign journey pass requireShortenerTouch: true.

The GTM leg

useGTMTracking().track() does one thing per call:

window.dataLayer.push({ event, ...params, timestamp: Date.now() }) — the bus adds timestamp, call sites never pass it. This is the only route an event takes to GTM, and it is skipped when dataLayer does not exist (no app.gtm-id, so no container loaded). In development it also logs [GTM Event] to the browser console.

⛔ The push is synchronous and waits for nothing. track() returns a promise only because call sites await it; the push has already happened by then. No server action belongs in this path. Until 2026-10-05 track() awaited the trackGTMEvent server action first, which cost three things:

  • Order. A click that navigates was pushed after the next page's page_view. Measured on ecommerceberlin.com: "Become an exhibitor" clicked on /, page_view for /exhibit at 22 ms, more_button_click at 183 ms, already on /exhibit.
  • The event itself, whenever the action failed: offline, or a tab left open across a deploy calling an action id the new build no longer has.
  • Time on the next real action. Next runs server actions one at a time, so pay_now_click held up the payment request and form_submit the registration by one round trip each.

trackGTMEvent (@eventjuicer/analytics/gtm-server-action) still exists and forwards one event to GTM_SERVER_CONTAINER_URL when that variable is set. No server container is configured, and nothing calls it per event. It writes no cookie: it used to park the last 10 events in a _gtm_events cookie for a GTMScript component to replay into dataLayer. That component was replaced as the loader by GTMConsentInit and mounted nowhere, so the cookie had no reader — while a cookie written in a server action makes Next re-render the route's server tree, once per tracked event. Both were removed on 2026-10-05.

Pinned by apps/ecommerceberlin/components/tracked-event-datalayer.test.ts (what GTM receives, in what order, with no server) and packages/analytics/src/gtm-server-action.test.ts (no cookie).


Mounting

Storefronts get everything from StorefrontShell (@eventjuicer/ecommerce/components/storefront-shell), which mounts, in order:

<GTMConsentInit gtmId={gtmId} /> {/* Consent Mode v2 defaults, THEN loads GTM */} <Suspense fallback={null}><GTMPageviewTracker /></Suspense> {/* page_view on every route change */} {children} <SilktideConsent config={…} /> {/* the banner that updates consent */} <Suspense fallback={null}> <RefererTracker externalActions={[saveReferer]} localActions={[saveLocalReferer]} /> </Suspense> <Suspense fallback={null}><ShortenerEnrichment /></Suspense> {/* = VisitorTracker → page_entrance */}

apps/vote and apps/ehandel mount the same pieces directly in their layouts (both including GTMConsentInit); apps/expojuicer mounts VisitorTracker under its own name.

  • GTMConsentInit must render before GTM loads. It reads the silktideCookieChoice_* keys out of localStorage, pushes gtag('consent', 'default', …) (denied unless previously granted, wait_for_update: 500), and only then injects gtm.js. It also guards on an existing #gtm-script so a double mount cannot initialise GTM twice.
  • GTMPageviewTracker must be wrapped in <Suspense> — it calls useSearchParams(), and an unwrapped dynamic hook opts the whole route out of static generation (universal rule #7).
  • ShortenerEnrichment is a re-export of VisitorTracker, kept so layouts that already mounted it gained entrance tracking with no edit. New code imports { VisitorTracker } from '@eventjuicer/shortener/visitor-tracker'.

RefererTracker — attribution cookies, not an event

The fourth analytics component, and the one easiest to forget because it raises no event at all. It runs two server actions on every route change and writes first-touch attribution into cookies.

ActionWritesFrom
saveRefererinitial_referer, initial_referer_14d/30d/90dthe referer request header
saveLocalRefererlocal_referer, local_referer_14d/30d/90dthe previous pathname (SPA navigation)

Rules that are easy to get wrong:

  • First touch wins. Each cookie is written only when it does not already exist, so the earliest referer within each window is the one kept. That is the whole point — a later internal navigation must not overwrite the campaign that brought the visitor in.
  • Four windows, one per entry in REFERER_COOKIE_DAYS ([90, 30, 14, 7]). The 7-day cookie is spelled without a suffix (initial_referer), the rest carry one. getAllReferers() reassembles them into {"90d": …, "30d": …}.
  • Same-host referers are reduced to their query string, and dropped entirely when there is no query string — internal navigation is not attribution. Only an external referer is stored whole.
  • Cookies are httpOnly, sameSite: 'lax', and secure in production. Read them server-side via getReferer() / getAllReferers(); the browser cannot.

Event reference

page_view

Route changes in Next.js. GTM only — raised by a direct dataLayer.push, not through the bus, so it never reaches /api/events.

Component: GTMPageviewTracker (@eventjuicer/analytics/gtm-pageview-tracker)

ParameterTypeDescriptionExample
page_pathstringusePathname()"/tickets"
page_locationstringPath plus query string — not an absolute URL"/tickets?role=visitor"
page_titlestringdocument.title"Tickets - EventJuicer"
timestampnumberDate.now()1234567890123

page_entrance

A real arrival on a storefront page. First-party ledger only — it is posted straight to /api/events and never pushed to dataLayer; page_view is GTM's equivalent.

Component: VisitorTracker (@eventjuicer/shortener/visitor-tracker)

ParameterTypeDescription
event_typestringalways "page_entrance"
event_idstringcrypto.randomUUID() per navigation — the dedup key
urlstringwindow.location.href
referrerstringdocument.referrer
localestringnavigator.language
handoffstring | nullthe _ejh param, when arriving from a sibling domain

Behaviour worth knowing before you touch it:

  • ?shortid, ?_sid and ?_ejh are consumed, then stripped from the URL with router.replace — after the event is accepted, so the cleanup navigation cannot manufacture a second visit. Strict Mode re-runs are guarded the same way.
  • A ?shortid arrival saves the shortener slug first; an ?_ejh handoff does not, because the handoff restores the original first touch and its remaining lifetime, and saving the slug would overwrite that with a fresh 14 days.
  • Delivery retries three times with exponential backoff (250ms · 3ⁿ).
  • A left click onto another HTTPS origin is intercepted once to mint a handoff URL (2s timeout, navigation always proceeds). No link is decorated or scanned in advance.

more_button_click

Components: MoreButton and RevealButton (@eventjuicer/ui/components/*)

MoreButton:

ParameterTypeDescriptionExample
labelstringButton label text"Learn More"
hrefstringDestination URL"/tickets"
is_externalbooleanisExternalLink(href)false

RevealButton fires the same event name with a different shape — it has no destination, so it reports the translation key it renders from:

ParameterTypeValue
baseLabelstringthe component's baseLabel prop
hrefstringalways "#more-button-reveal"
is_externalbooleanalways false

⚠️ baseLabel vs label is a real inconsistency in the wire format, not a typo in this document. A GTM tag reading label sees undefined for every reveal.


add_to_cart

Components: AddToCartButton, BoothAddToCartButton (@eventjuicer/ecommerce/components/*)

ParameterTypeRaised byDescription
ticket_idnumberbothTicket id
ticket_namestringbothticket.name
quantitynumberbothStandard button uses the chosen quantity; booth always 1
pricenumberbothDisplay price (converted, see below)
currencystringbothDisplay currency
rolestringbothticket.role — "visitor", "exhibitor", …
has_optionsbooleanbothWhether options were selected; booth always false
company_idnumberstandard, via trackingDataExhibitor company id — the exhibitor profile's badge-buddy tab
company_namestringstandard, via trackingDatacompany.name — same caller
predefinedbooleanstandard, via trackingDataalways true — same caller
booth_idstring | numberboothBooth being reserved
booth_namestringboothBooth label

This event reaches both sinks. ticket_id, company_id and booth_id are what /api/events de-duplicates on.


view_cart

Fires from an effect when the cart drawer opens with at least one item.

Component: CartPreview (@eventjuicer/ecommerce/components/cart-preview)

ParameterTypeDescription
cart_items_countnumbertotalItems
cart_total_valuenumbertoDisplayLinesTotal(...).amount
currencystringdisplayCurrency
itemsarray{ ticket_id, ticket_name, quantity, price, role } per line

⛔ Currency contract — do not "fix" this to the base currency. All three cart events report displayCurrency and toDisplayPrice/toDisplayLinesTotal figures, because pay_now_click reports the charged currency. Reporting raw basePriceCurrency here would split one funnel across two denominations and no GA4 revenue report could reconcile across the checkout boundary. These are also net figures: toBePaid grosses up by vatRate, which is a different concern and one this call site never had. With no invoice currency configured they are byte-identical to the raw values — pinned in invoice-currency-display.test.ts.


proceed_to_checkout

The "Proceed to Checkout" button in the cart drawer. Same component, same parameters and same currency contract as view_cart.


clear_cart

The "Clear Cart" button, after the confirm dialog is accepted.

ParameterTypeDescription
cart_items_countnumberItems about to be discarded
cart_total_valuenumberDisplay total
currencystringdisplayCurrency

No items array.


pay_now_click

Raised immediately before createPaymentRequest, so it counts intent, not a completed payment.

Component: PayNowButton (@eventjuicer/ecommerce/components/paynow-button)

ParameterTypeDescription
purchase_idnumberParticipantPurchase id
amountnumbertoBePaid — the amount actually being charged
currencystringchargedCurrency
items_countnumberorderData.purchase.tickets.length
ctxstringCheckout context, e.g. "entry_tickets"

download_invoice_click

Raised after the document comes back, so invoice_type and already_exists describe what was really served.

Component: DownloadInvoiceButton (@eventjuicer/invoice/components/download-invoice-button)

ParameterTypeDescription
purchase_idnumberPurchase the document belongs to
invoice_typestring"proforma" | "vat" | "correction" (defaults to "proforma")
already_existsbooleanThe document was fetched, not freshly issued

form_start

The first change on any field of a form instance, tracked once per mount. Reaches both sinks.

Components: SmartForm, Newsletter, RequestACall (@eventjuicer/forms/*)

ParameterTypeDescription
form_idstringSmartForm: the id prop. Newsletter: "newsletter". RequestACall: "request-a-call"
base_labelstringTranslation base label, e.g. "checkout.fields"
field_namestringField that was touched first

form_id is the /api/events dedup key.


form_submit

ParameterTypeDescription
form_idstringas above
base_labelstringas above
fields_countnumberSmartForm: live field count. Newsletter: 2. RequestACall: 5

SmartForm tracking is switchable. The trackingEnabled prop (default true) gates both form_start and form_submit. Operator-facing forms embedded in admin pass trackingEnabled={false} so staff activity never lands in visitor analytics.


Adding a tracked interaction

'use client'; import { useTrackedEvent } from '@eventjuicer/analytics/use-tracked-event'; export function MyComponent() { const { track } = useTrackedEvent(); return ( <button onClick={() => track('my_event', { thing_id: 123, label: 'Thing' })}> Do it </button> ); }
  • Never pass timestamp — the bus adds it.
  • Params must survive JSON.stringify and the server-action boundary: GTMEventValue allows strings, numbers, booleans, null, arrays and plain objects. No dates, class instances or functions.
  • A new name goes to GTM only. To persist it first-party as well, add it to both SA_EVENT_TYPES (use-tracked-event.ts) and TRACKABLE_EVENT_TYPES (@eventjuicer/shortener/event-sink), and give buildDedupSubject a rule for it — otherwise every repeat writes another row.

Declarative wrapper

Rarely needed, but it exists:

  • GTMTrackInteraction — client component, trackOn="click" | "hover" | "focus" | "visible".

It goes straight to a dataLayer push, and therefore skips the first-party ledger.

There is no server-rendered tracker. GTMTrackView / GTMTrackSection tracked on render through the server action alone, so the _gtm_events cookie was their only way into dataLayer; they went with it.