An Event Definition turns one or more raw auto-kind events into a named conversion. It's the analytics-side counterpart to a Tag Manager Tag of type event — except where Tag Manager fires client-side, an Event Definition is server-side and runs every time processBatch() ingests a row.
The goal: keep auto-collection broad and noisy ("we capture every click"), then have a small layer of human-named conversions on top ("add_to_cart is a click on #buy-now"). Conversions stay queryable by name in funnels, cohorts, and dashboards without anyone going back to the customer site.
{
_id: ObjectId(...),
siteId: ObjectId(...),
name: 'add_to_cart',
description: 'User clicked the primary CTA on a product page',
status: 'active', // 'active' | 'paused' | 'archived'
matchRules: [
{ field: 'event_name', op: 'equals', value: 'click' },
{ field: 'properties.selector_path', op: 'matches', value: '#buy-now' },
{ field: 'page_path', op: 'starts_with', value: '/product/' },
],
matchMode: 'all', // 'all' (AND) | 'any' (OR)
derivedEventName: 'add_to_cart', // the synthetic name written to BQ
derivedEventKind: 'custom', // 'custom' (default) | 'tag'
propertyExtractors: [
{ name: 'product_id', from: 'properties.id' },
],
lastMatchedAt: Date(...),
totalMatches: 1247,
createdBy: ObjectId(...),
createdAt: Date(...),
}
op |
Semantics |
|---|---|
equals |
Exact (===) comparison. |
not_equals |
Exact inequality. |
contains |
String includes (case-sensitive). |
starts_with |
String prefix. |
ends_with |
String suffix. |
matches |
Treated as CSS selector for selector_path fields; treated as regex for all other fields. |
gt / gte / lt / lte |
Numeric comparison (auto-coerces). |
exists |
Truthy presence. |
not_exists |
Falsy / missing. |
field paths support dot notation into the event object, including properties.*, utm.*, and consent.*.
Inside processBatch():
eventDefinitionMatcher.deriveBatch(rows, definitions).matchMode (all = AND across rules, any = OR).event_name replaced by derivedEventName, event_kind set to derivedEventKind, and any propertyExtractors materialized into properties.*.recordMatchStats() increments totalMatches and updates lastMatchedAt (async, doesn't block the response).A single source event can produce multiple derived rows if it matches multiple definitions. There is no precedence — they all fire.
Open Web Analytics > Event Definitions. The page lists every definition for the site with its totalMatches last-30-days, lastMatchedAt, and a sparkline showing daily match volume.
The hourly AI Suggestions job is the discovery surface for new definitions you should create.
How it works:
Once per hour, analyticsAiSuggestionsJob.js (Cloud Run Job, triggered via /api/analytics/ai-suggest-tick) runs for every active site.
For each site it samples up to 2,000 event_kind = 'auto' rows from the last hour, stratified by (selector_path, page_path, event_name) so it sees the diversity of activity instead of just the dominant cluster.
The sample plus a compact summary (top 20 clusters, total volume, distinct visitors) goes to Gemini with the tool declaration:
propose_named_event({
name: string, // snake_case
description: string, // 1-2 sentence rationale
matchRules: MatchRule[],
matchMode: 'all' | 'any',
suggestedFunnelStep: string | null, // hint for adding to a funnel
confidence: number, // 0-1
})
Every proposal is upserted into aiTagSuggestionModel keyed on a hash of (name, matchRules) so re-proposals don't create dupes.
The Suggestions Inbox UI shows proposals ranked by confidence × estimatedMatchesPerDay.
Accept creates the EventDefinition immediately. Dismiss marks the suggestion status: 'dismissed' so it won't be re-proposed.
Dismiss + tell us why captures freeform feedback that's included in the next prompt so the model can avoid that class of suggestion.
The job is cron-triggered via the ticks system using the x-tick-secret header — see orchestrations docs for the cron infrastructure. AI cost is logged in llmUsageModel per site so you can attribute spend.
Derived rows have:
event_id (UUID v4 — no relationship to the source event_id).event_ts, visitor_id, session_id, user_id, page fields, UTM, device/geo, and consent state as the source.event_name = derivedEventName, event_kind = derivedEventKind.properties reflecting propertyExtractors (the source's properties is not preserved — if you need source properties on the derived row, add them to propertyExtractors).received_ts as the source (so derived + source are co-located in time).In the events table these look like any other event. Funnel and cohort queries treat them as first-class.
click[selector=#buy-now] → add_to_cart.matchMode: 'any'): A "conversion" that can be triggered by either a click OR a form submit OR a custom JS call.form_submit on /contact might also count as lead_captured AND as marketing_form_completed — both definitions fire.click on body (i.e. clicked anywhere) producing a page_engaged definition with conditions that require it NOT to be on a known link. Use not_equals and not_exists ops to construct exclusions.event_id in queries if needed.