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, notuseGTMTracking. Adding a sink (GA4 direct, PostHog, …) is a change there, never at a call site. useTrackedEventwrapsuseGTMTracking()from@eventjuicer/analytics/gtm-track-interaction, which is still the GTM leg. CallinguseGTMTrackingdirectly skips the ledger — don't, in new code.SA_EVENT_TYPESinuse-tracked-event.tsis the allowlist for sink 1:page_entrance,add_to_cart,form_start. It mirrorsTRACKABLE_EVENT_TYPESin@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:
| event | keys used for dedup |
|---|---|
add_to_cart | ticket_id, company_id, booth_id, path |
form_start | form_id, path |
page_entrance | event_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_viewfor/exhibitat 22 ms,more_button_clickat 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_clickheld up the payment request andform_submitthe 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.
GTMConsentInitmust render before GTM loads. It reads thesilktideCookieChoice_*keys out oflocalStorage, pushesgtag('consent', 'default', …)(denied unless previously granted,wait_for_update: 500), and only then injectsgtm.js. It also guards on an existing#gtm-scriptso a double mount cannot initialise GTM twice.GTMPageviewTrackermust be wrapped in<Suspense>— it callsuseSearchParams(), and an unwrapped dynamic hook opts the whole route out of static generation (universal rule #7).ShortenerEnrichmentis a re-export ofVisitorTracker, 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.
| Action | Writes | From |
|---|---|---|
saveReferer | initial_referer, initial_referer_14d/30d/90d | the referer request header |
saveLocalReferer | local_referer, local_referer_14d/30d/90d | the 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', andsecurein production. Read them server-side viagetReferer()/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)
| Parameter | Type | Description | Example |
|---|---|---|---|
page_path | string | usePathname() | "/tickets" |
page_location | string | Path plus query string — not an absolute URL | "/tickets?role=visitor" |
page_title | string | document.title | "Tickets - EventJuicer" |
timestamp | number | Date.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)
| Parameter | Type | Description |
|---|---|---|
event_type | string | always "page_entrance" |
event_id | string | crypto.randomUUID() per navigation — the dedup key |
url | string | window.location.href |
referrer | string | document.referrer |
locale | string | navigator.language |
handoff | string | null | the _ejh param, when arriving from a sibling domain |
Behaviour worth knowing before you touch it:
?shortid,?_sidand?_ejhare consumed, then stripped from the URL withrouter.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
?shortidarrival saves the shortener slug first; an?_ejhhandoff 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:
| Parameter | Type | Description | Example |
|---|---|---|---|
label | string | Button label text | "Learn More" |
href | string | Destination URL | "/tickets" |
is_external | boolean | isExternalLink(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:
| Parameter | Type | Value |
|---|---|---|
baseLabel | string | the component's baseLabel prop |
href | string | always "#more-button-reveal" |
is_external | boolean | always false |
⚠️
baseLabelvslabelis a real inconsistency in the wire format, not a typo in this document. A GTM tag readinglabelseesundefinedfor every reveal.
add_to_cart
Components: AddToCartButton, BoothAddToCartButton (@eventjuicer/ecommerce/components/*)
| Parameter | Type | Raised by | Description |
|---|---|---|---|
ticket_id | number | both | Ticket id |
ticket_name | string | both | ticket.name |
quantity | number | both | Standard button uses the chosen quantity; booth always 1 |
price | number | both | Display price (converted, see below) |
currency | string | both | Display currency |
role | string | both | ticket.role — "visitor", "exhibitor", … |
has_options | boolean | both | Whether options were selected; booth always false |
company_id | number | standard, via trackingData | Exhibitor company id — the exhibitor profile's badge-buddy tab |
company_name | string | standard, via trackingData | company.name — same caller |
predefined | boolean | standard, via trackingData | always true — same caller |
booth_id | string | number | booth | Booth being reserved |
booth_name | string | booth | Booth 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)
| Parameter | Type | Description |
|---|---|---|
cart_items_count | number | totalItems |
cart_total_value | number | toDisplayLinesTotal(...).amount |
currency | string | displayCurrency |
items | array | { 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.
| Parameter | Type | Description |
|---|---|---|
cart_items_count | number | Items about to be discarded |
cart_total_value | number | Display total |
currency | string | displayCurrency |
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)
| Parameter | Type | Description |
|---|---|---|
purchase_id | number | ParticipantPurchase id |
amount | number | toBePaid — the amount actually being charged |
currency | string | chargedCurrency |
items_count | number | orderData.purchase.tickets.length |
ctx | string | Checkout 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)
| Parameter | Type | Description |
|---|---|---|
purchase_id | number | Purchase the document belongs to |
invoice_type | string | "proforma" | "vat" | "correction" (defaults to "proforma") |
already_exists | boolean | The 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/*)
| Parameter | Type | Description |
|---|---|---|
form_id | string | SmartForm: the id prop. Newsletter: "newsletter". RequestACall: "request-a-call" |
base_label | string | Translation base label, e.g. "checkout.fields" |
field_name | string | Field that was touched first |
form_id is the /api/events dedup key.
form_submit
| Parameter | Type | Description |
|---|---|---|
form_id | string | as above |
base_label | string | as above |
fields_count | number | SmartForm: 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.stringifyand the server-action boundary:GTMEventValueallows 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) andTRACKABLE_EVENT_TYPES(@eventjuicer/shortener/event-sink), and givebuildDedupSubjecta 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.