# Event Definitions

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.

## The Shape of a Definition

```js
{
  _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(...),
}
```

## Match Rules

| `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.*`.

## How Matching Runs

Inside `processBatch()`:

1. After validation and site guards, the rows are passed to `eventDefinitionMatcher.deriveBatch(rows, definitions)`.
2. For each row, every active definition is tested. The check uses the `matchMode` (`all` = AND across rules, `any` = OR).
3. Every match produces a **derived row** — a clone of the source row with `event_name` replaced by `derivedEventName`, `event_kind` set to `derivedEventKind`, and any `propertyExtractors` materialized into `properties.*`.
4. Both the source row and the derived row are written to BigQuery — the source so the raw auto stream stays complete, the derived so it shows up as a named event everywhere.
5. `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.

## CRUD via UI

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.

- **Create** opens a form with a live "test against last 1000 events" preview — paste a sample event JSON or pick from a dropdown of recent events; the UI shows whether the current rules match it before saving.
- **Edit** doesn't replay historical data — only future events match the new rules. To backfill, run the bootstrap **Backfill Event Definitions** action from the same page (BigQuery scan + insert of derived rows for matching historical rows, scoped to the lookback window you pick).
- **Pause** stops the definition from matching new events but preserves history.
- **Archive** preserves history and hides the definition from dropdowns.

## AI Suggestions Inbox

The hourly **AI Suggestions** job is the discovery surface for new definitions you should create.

How it works:

1. Once per hour, `analyticsAiSuggestionsJob.js` (Cloud Run Job, triggered via `/api/analytics/ai-suggest-tick`) runs for every active site.
2. 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.
3. The sample plus a compact summary (top 20 clusters, total volume, distinct visitors) goes to Gemini with the tool declaration:

   ```ts
   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
   })
   ```

4. Every proposal is upserted into `aiTagSuggestionModel` keyed on a hash of `(name, matchRules)` so re-proposals don't create dupes.
5. The Suggestions Inbox UI shows proposals ranked by `confidence × estimatedMatchesPerDay`.
6. **Accept** creates the `EventDefinition` immediately. **Dismiss** marks the suggestion `status: 'dismissed'` so it won't be re-proposed.
7. **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.

## When Derived Rows Land in BigQuery

Derived rows have:

- A new `event_id` (UUID v4 — no relationship to the source `event_id`).
- The same `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`).
- The same `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.

## Common Patterns

- **One auto event → one definition**: 90% of cases. `click[selector=#buy-now]` → `add_to_cart`.
- **Multiple auto events → one definition** (`matchMode: 'any'`): A "conversion" that can be triggered by either a click OR a form submit OR a custom JS call.
- **One auto event → many definitions**: A `form_submit` on `/contact` might also count as `lead_captured` AND as `marketing_form_completed` — both definitions fire.
- **Filter out a generic event**: An auto `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.

## What Definitions Don't Do

- They don't deduplicate — if the same source event matches the same definition twice (e.g. double-tap on mobile), you get two derived rows. Dedupe by `event_id` in queries if needed.
- They don't run against historical rows automatically (the Backfill action does that on-demand).
- They don't fire client-side — that's what Tag Manager tags are for. Use a Tag if the action needs to happen *in the browser* (sending to Facebook Pixel, etc.); use a definition if the action only needs to happen *in your analytics*.
