# Snowplow Event Dependencies & Migration Risk

**Generated**: 2026-06-01
**Scope**: Snowplow event lineage from raw ingestion (`mammoth.report.events`) through every downstream consumer across four repos — **mammoth** (warehouse/ETL), **cortex** (importers + internal-link PageRank graph), **marketing** (weekly dashboards), and **seo-data-transformer-pipeline** (the `acq` database) — plus a migration-risk health check for the `ui_react → nexus/canopy` web-client change.

**How to read this**:
- **Executive summary** — dependencies + per-repo exposure/risk (start here).
- **Layers 0–3** + **Structured / Non-structured event catalogs** — mammoth-internal lineage reference.
- **Cross-repo** sections — consumers in cortex, marketing, and seo-data-transformer-pipeline.
- **Client migration health check** — the deep dive on the core risk, with reusable monitoring SQL.

**Path convention**: unqualified file paths (e.g. `warehouse/…`, `dbt/models/…`, `bin/…`) are relative to the **mammoth** repo root. Paths in the cross-repo sections are relative to the named repo (`cortex`, `marketing`, `seo-data-transformer-pipeline`). All repos live under the same Wanderu repos directory.

**mammoth internal flow**: `report.events` (raw) → materialize → `report.snowplow_*` tables → {NPS, fact_transaction enrichment, analytics, SEO scoring, installed_apps, Kayak, fraud, wtickets} → {dbt `sessions_by_day` / `transactions` / `transactions_acquisition`, c2c_performance, dynamic_content exporters}. Mobile branch: `report.vw_snowplow_events` → `core.int_events_snowplow`.

---

## Executive summary

**The dependency split that governs all risk:** two kinds of Snowplow data flow downstream, and they have opposite migration fates as the web client moves `ui_react` → `nexus`/`canopy`:

- **Session counts & attribution (ROBUST).** Built from canonical `page_view`/`page_ping` events and `snowplow_session_key`. Survive the migration because the new clients still emit page events. Powers SEO/content ranking, channel/revenue attribution, and session dashboards.
- **Structured-event engagement/funnel metrics (FRAGILE).** Built from `event='struct'` (`se_category`/`se_action`/`se_label`) — search, checkout, livecheck, tripsummary, travel-mode, treatments, plus `transaction_item` booking clicks. **nexus emits almost none of these; canopy emits zero.** These columns dilute toward zero as `ui_react` retires, silently (no `app_id` on `snowplow_sessions` to segment).

**Already-observed (context, not all incidents):** `split_test/assigned` (A/B treatments) went to ~zero in Oct 2025 — **intentional**, we stopped running wboard-powered A/B tests, so the treatment columns are empty by design (not a tracking break). `livecheck_completed` 10× over-fired Aug–Sep 2025 (a real data-quality blip). `search_submitted` halved over the year (behavioral, not a break). The marketing `c2c-nexus-kpis.sql` dashboard already hard-codes a workaround for the Apr 22–May 14 2026 `search_submitted` outage on the nexus cohort.

### Exposure & risk by repo

| Repo | What depends on Snowplow | Exposure | Risk |
|---|---|---|---|
| **mammoth** | `report.snowplow_*` tables + downstream: NPS, `fact_transaction` attribution, SEO scoring (`c2c_performance[_weekly]`), dynamic-content/page-link exporters, Kayak, fraud, dbt `transactions`/`sessions_by_day` | Mixed | SEO/export path = session-count (robust). Funnel/treatment analytics on `snowplow_sessions` = fragile. SEO business logic (e.g. station ranking) uses `fact_transaction`, **not** Snowplow. |
| **cortex** | 7 importers via `export.*`→S3→postgres, incl. the C2C recommended-link **PageRank graph** (`build_graph` / `load_goal.py`) | Session-count only | **Low-moderate.** All use `snowplow_session_count` for ranking/inclusion; blended with GetStat + commission revenue (non-Snowplow) fallbacks. No funnel-event exposure. Dilution/regional-skew risk only. |
| **marketing** (weekly dashboards) | direct queries on `snowplow_sessions`/`snowplow_app_sessions` + `snowplow_session_key` | Mixed | `funnel.sql` + 5 search/route KPIs = **fragile** (engagement columns). Session/attribution/booking dashboards = robust. `c2c-nexus-kpis.sql` already self-patched. |
| **seo-data-transformer-pipeline** (`acq` DB) | mammoth Snowflake shares of `report.events` + `snowplow_user_account_events`/`insurance_quotes`/`c2c_performance_weekly` | Mixed | Ad-click attribution (URL params) + `api_sitemap_links` (session-count) = robust. **Fragile:** meta-ads `facebook_pixel` pixel models + `blueshift__search_events` (account struct events). Produces the `acq.seo.*` tables cortex reads. |

**Specific structured events each repo depends on** (notation `se_category / se_action [/ se_label]`; these are the migration-fragile inputs — `nexus` emits almost none, `canopy` none):

- **mammoth** — `report.snowplow_sessions` engagement columns are fed by: `split_test / assigned` (treatments, now intentionally idle); `{search, react_search, search_widget, search_results, Search Widget} / submitted` (search_submitted); `{search, react_search, search_results} / select_result` (result_selected); `{live_check, react_livecheck} / itinerary_completed` (livecheck_*); `react_tripsummary / loaded` (tripsummary_loaded); `checkout / click [submit|retry]` and `checkout / loaded` (booking_form_*); `react_search / click [travel_mode_*]` (travel_mode toggles). Other mammoth tables also use `user_accounts / *` (snowplow_user_account_events), `checkout / loaded` + insurance payload (snowplow_insurance_quotes), and `{ios,android}_attribution` + `app_launch / app_version` (mobile installs / app sessions). *(Canonical, not structured: `transaction_item` w/ `ti_category IN (deeplink, wanderu_native)` → booking_clicked/deeplinked.)*
- **cortex** — **none directly.** Consumes only `snowplow_session_count` (page-view-derived). No structured-event dependency.
- **marketing** — `funnel.sql`: `{search,react_search,search_results} / select_result` + `react_tripsummary / loaded` (+ canonical `transaction_item`); search KPIs (`c2c-search-did`, `wc-daily-search`, `{c2c,wc}-per-route-summary`, `c2c-nexus-kpis`): `search* / submitted`; `app-installs.sql`: `{ios,android}_attribution` (mobile, unaffected by the web migration).
- **seo-data-transformer-pipeline** (`acq`) — meta-ads pixel models: `facebook_pixel / loaded` (parses `se_property:fbc/fbp`); `blueshift__search_events`: `user_accounts / {create_account, login}`. *(Ad-click models read URL params off canonical page events — not structured; `api_sitemap_links` uses session counts — robust.)*

**Top actions:** (1) gate `ui_react` retirement on structured-event parity from nexus/canopy (track via the Q7 coverage matrix); (2) add `app_id` to `report.snowplow_sessions` so dilution is detectable; (3) fix nexus's mis-implemented `c2c_search_widget/search_submitted`; (4) flag the meta-ads `facebook_pixel` paid-attribution dependency to marketing/SEO. (Note: the `split_test/assigned` drop is **expected** — A/B testing via wboard was discontinued.) Full detail in **Client migration health check** below.

---

## Layer 0 — Raw ingestion

| Object | Produced by | Notes |
|---|---|---|
| `report.events` | `warehouse/importer/snowplow/snowplow_loader.py` | The raw Snowplow events table. COPYs enriched events from S3 stage → `import.events` → `report.events`, adding `bot_flag` via `report.udf_is_bot(...)`. Entry: `bin/load_snowplow.py`, `bin/reload_failed_snowplow_files.py` (`warehouse/importer/snowplow/reload_error_files.py`) |
| `report.vw_snowplow_events` | (view over `report.events`, defined in Snowflake/migrations) | Read by the mobile int_events transformer |
| `import.events` | loader staging | transient import table |

**Cleansing**: `bin/cleanse_snowplow_events.py` → `warehouse/transformer/cleanse_snowplow_events.py` (dedup/cleanup of `report.events`).

---

## Layer 1 — Materialized Snowplow tables (built FROM `report.events`)

All produced by `bin/materialize_snowplow.py` → `warehouse/transformer/materialize_snowplow.py`, with SQL in `warehouse/sql/template/snowplow/materialized_tables.py`:

- `report.snowplow_page_views`
- `report.snowplow_sessions` (merge)
- `report.snowplow_app_sessions` (merge)
- `report.snowplow_sessions_mapping`
- `report.snowplow_booking_events`
- `report.snowplow_mobile_app_events`
- `report.snowplow_user_account_events`
- `report.snowplow_insurance_quotes`
- `report.snowplow_ios_installs`, `report.snowplow_ios_screen_views`
- `report.snowplow_android_installs`, `report.snowplow_android_screen_views`

Schema-altering migration: `migrations/20250521003734269127_alter_snowplow_mobile_app_events.py`.

---

## Structured events consumed by Layer 1 tables

Enumeration of the **structured Snowplow events** (`event = 'struct'`, identified by `se_category` / `se_action` / `se_label` / `se_property`) that each Layer 1 materialization reads from `report.events`. SQL source: `warehouse/sql/template/snowplow/materialized_tables.py`; orchestration: `warehouse/transformer/materialize_snowplow.py`.

### `report.snowplow_sessions` (web) — `RECREATE_SESSIONS_MERGE`

| se_category | se_action | se_label / condition | Drives |
|---|---|---|---|
| `split_test` | `assigned` | `se_property.treatments` present | treatment assignment |
| *(any)* | `submitted` | `se_label='clicktripz_ad'` | exit_unit_triggered |
| `search`, `Search Widget`, `search_widget`, `search_results` | `submitted` | `se_label` valid | search_submitted |
| `search`, `react_search`, `search_results` | `select_result` | depart (`se_label='depart'` / trip_type) | depart_result_selected |
| `search`, `react_search`, `search_results` | `select_result` | return | return_result_selected |
| `live_check`, `react_livecheck` | `itinerary_completed` | `error` / `!=error` / `!=success_unavailable` | livecheck_completed_error / _completed / _success |
| `react_tripsummary` | `loaded` | — | tripsummary_loaded |
| `checkout` | `click` | `submit`, `retry` | booking_form_submitted |
| `react_search` | `≠ select_result` | not `more_results`/`directions`/`view_directions` | filter_interactions |
| `react_search` | `click` | `travel_mode_bus`/`_train`/`_multi`/`_ferry`/`_minibus` | travel_mode_*_toggled |
| `search_widget` | `submitted` | `returnDate` empty / non-empty | one_way_/return_searches_submitted |
| `checkout` | `loaded` | — | booking_form_loaded |

### `report.snowplow_app_sessions` (app) — `RECREATE_APP_SESSIONS_MERGE` (reads `snowplow_mobile_app_events`)

| se_category / event_name | se_action | se_label | Drives |
|---|---|---|---|
| `split_test` | `assigned` | — | treatment |
| `app_launch` | `app_version` | `se_label`=version, `se_property`=build | first_app_launch |
| `search`, `search_results` | `submitted` | — | search_submitted |
| `search`, `search_results` | `select_result` | depart / return | result_selected |
| `live_check` | `trip_completed` | `error` / `!=error` | livecheck_completed_* |
| `checkout` | `click` | — | booking_form_submitted |
| event_name `booking_form` | `click` | `creditcard` / `apple_pay` / `google_pay` | select_creditcard / _apple_pay / _google_pay |
| `≠ app_launch` | — | — | non_app_launch_count (session qualifier) |

### `report.snowplow_booking_events` — `INSERT_BOOKING_EVENTS`
- `se_category='checkout'` AND `se_action='click'` AND `se_label='submit'` (captures email from `se_property`)

### `report.snowplow_ios_installs` — `INSERT_IOS_INSTALLS`
- `event='struct'` (explicit) AND `se_category='ios_attribution'` (`name_tracker='com.wanderu.Wanderu'`, `platform='mob'`). `se_label`=app_version; `se_property`=Apple iAd attribution JSON (`Version3.1` keys). Code comment: *"Attribution events are all formatted as structured events."*

### `report.snowplow_android_installs` — `INSERT_ANDROID_INSTALLS`
- `se_category='android_attribution'` (`name_tracker` in `com.wanderu.wanderu`/`com.wanderu.turimex`, `platform='mob'`). `se_label`=app_version; `se_property`=attribution UTM params (`utm_medium/source/term/content/campaign`).

### `report.snowplow_user_account_events` — `INSERT_USER_ACCOUNT_EVENTS`
- `event='struct'` AND `se_category='user_accounts'` (stores `se_category`/`se_action`/`se_label`/`se_property`/`se_value` verbatim).

### `report.snowplow_insurance_quotes` — `INSERT_INSURANCE_QUOTES`
- `se_category='checkout'` AND `se_action='loaded'` AND `se_property.insurance` not null (parses quoteId/productId/pricing from `se_property`).

### `report.snowplow_mobile_app_events` — `INSERT_APP_EVENTS`
- Mobile capture keyed on `event_name='screen_view'` (+ native `app_id`s), but **carries through all structured-event columns** (`se_category`, `se_action`, `se_label`, `se_property`, `se_value`, plus `event`, `ti_category`). Feeds `snowplow_app_sessions`.

### `report.treatment` / `report.treatment_experiment` — `load_treatments` (runs first in the same job)
- `se_category='split_test'` AND `se_action='assigned'`, flattening `se_property.treatments[]` → experiment id/name/group.

---

## Non-structured events consumed by Layer 1 tables

The **non-structured** events Snowplow emits fall into two groups:

- **Canonical events** — `event` column: `page_view`, `page_ping`, `transaction_item`.
- **Self-describing (unstruct) events** — `event_name` column with payload in `unstruct_event` JSON: primarily `screen_view` (mobile).

SQL source: `warehouse/sql/template/snowplow/materialized_tables.py`; orchestration: `warehouse/transformer/materialize_snowplow.py`.

### `report.snowplow_page_views` — `INSERT_PAGE_VIEWS`
- `event='page_view'` (canonical) → full page-view record (page URL components, referrer, geo, browser/OS/device, IP lookups).

### `report.snowplow_sessions` (web) — `RECREATE_SESSIONS_MERGE`
| event / condition | Drives |
|---|---|
| `event IN ('page_view','page_ping')` | ranked_session_pages — page sequencing, first/last page, traffic_source, affiliate code |
| `event='page_view'` (count) | page_view_count |
| `event='page_ping'` (count) | page_ping_count |
| `event='transaction_item'` AND `ti_category IN ('deeplink','wanderu_native')` | booking_clicked |
| `event='transaction_item'` AND `ti_category='deeplink'` | deeplinked |
| `event IN ('page_view','page_ping')` AND `page_url LIKE '%depart%'` / `'%return%'` | depart_results_loaded / return_results_loaded |

### `report.snowplow_app_sessions` (app) — `RECREATE_APP_SESSIONS_MERGE` (reads `snowplow_mobile_app_events`)
Screen-view (self-describing) metrics, keyed on `event_name='screen_view'` + `screen_name`:
| `screen_name` | Drives |
|---|---|
| `ticket summary screen` | tripsummary_loaded |
| `carrier filter screen`, `features dialog` | filter_interactions |
| `departure search results screen` | depart_results_loaded |
| `return search results screen` | return_results_loaded |
| `booking screen`, `native booking screen`, `webview booking screen` | booking_form_loaded |
| `my trips screen` | mytrips_loaded |

### `report.snowplow_ios_screen_views` — `INSERT_IOS_SCREEN_VIEWS`
- `event_name='screen_view'` (`platform='mob'`, `name_tracker='com.wanderu.Wanderu'`); `screen_name` parsed from `unstruct_event:data:data:name`.

### `report.snowplow_android_screen_views` — `INSERT_ANDROID_SCREEN_VIEWS`
- `event_name='screen_view'` (`platform='mob'`, `name_tracker IN ('com.wanderu.wanderu','com.wanderu.turimex')`); `screen_name` from `unstruct_event`.

### `report.snowplow_mobile_app_events` — `INSERT_APP_EVENTS`
- Captured on `event_name='screen_view'` (mobile trackers) **OR** native `app_id IN ('wanderu_ios','wanderu_beta_ios','wanderu_android','wanderu_beta_android','turimex_android')`; `screen_name` from `unstruct_event`. (Also carries the structured `se_*` columns through — see structured section.)

### `report.snowplow_sessions_mapping` — `INSERT_MAPPING_RECORDS`
- No event-type filter — selects all events by `network_userid` / `session_key`.

### Snowplow context entities (not events, for completeness)
The mobile install/screen-view/app-event tables also flatten self-describing **context entities** from the `contexts` JSON (joined per `event_fingerprint`), not events:
- `client_session` (sessionId, previousSessionId, sessionIndex, storageMechanism)
- `mobile_context` (osType, osVersion, carrier, deviceManufacturer/Model, appleIdfa/Idfv, androidIdfa, networkType/Technology)
- `geolocation_context`

> Note: `INSERT_MARKETING_CLICKS` filters `event='page_view'` (non-structured) but is **not** in `materialize_snowplow.py`'s load configs, so it is not part of this Layer 1 job.

---

## Layer 2 — Direct consumers of raw / materialized Snowplow tables

| Consumer | File | Reads |
|---|---|---|
| `report.snowplow_nps_survey_scores` | `warehouse/transformer/snowplow_nps_survey_scores.py` (`bin/upsert_snowplow_nps_survey_scores.py`) | `report.events` (+ `report.fact_transaction`) |
| `core.int_events_snowplow` | `warehouse/transformer/core__int_events_snowplow.py` (`bin/load_core__int_events_snowplow.py`, GHA `z-core-schema-load-core_int-events-snowplow.yaml`) | `report.vw_snowplow_events` (mobile, platform='mob') |
| `report.fact_transaction` (snowplow enrichment) | `warehouse/sql/template/transformer/dimensional_facts.py` | populates `snowplow_domain_id`, `snowplow_network_id`, `snowplow_session_key`, `snowplow_id` |
| analytics user-mapping | `warehouse/sql/template/transformer/materialize_analytics.py` | `report.snowplow_sessions` |
| SEO scoring | `warehouse/sql/template/transformer/seo_scoring.py` | `report.snowplow_sessions` → emits `snowplow_session_count`, `snowplow_seo_session_count` |
| installed-apps report | `warehouse/sql/template/installed_apps.py` | `report.snowplow_ios_installs`, `report.snowplow_android_installs` |
| Kayak export | `warehouse/sql/template/exporter/kayak/generate.py` | `report.snowplow_sessions` |
| booking events | `warehouse/transformer/booking_events.py` (`bin/booking_events.py`) | `snowplow_network_userid` |
| wtickets consolidation | `warehouse/importer/consolidated_wtickets/consolidate_transactions.py`, `warehouse/sql/template/importer/consolidated_wtickets.py` | `snowplow_id` |
| fraud link analysis | `warehouse/fraud/links.py`, `warehouse/fraud/fraud_checker.py` | `snowplow_id` / related-email queries |
| wanderlist sorting | `warehouse/sql/template/transformer/wanderlist_sorting.py` | `snowplow_network_id`, `snowplow_id` |

**dbt sources** (`dbt/models/sources/report.yml`): `snowplow_app_sessions`, `snowplow_sessions`.

**dbt model directly on those sources**:
- `dbt/models/helpers/sessions_by_day.sql` → `source('report','snowplow_app_sessions')` + `source('report','snowplow_sessions')`

---

## Layer 3 — Downstream (transitive)

**dbt**:
- `dbt/models/helpers/sessions_and_rev_by_day.sql` → `ref('sessions_by_day')`
- `dbt/models/report/transactions.sql` → from `fact_transaction`, exposes `snowplow_domain_id` / `snowplow_network_id` / `snowplow_session_key`
- `dbt/models/report/transactions_acquisition.sql` → `ref('transactions')` (carries the snowplow_* columns)
- dbt test: `dbt/tests/assert_report_events_bot_flag_not_null.sql` (asserts on `report.events`)

**c2c performance → dynamic content** (consume the `snowplow_session_count` columns emitted by seo_scoring / c2c perf):
- `warehouse/exporter/dynamic_content/c2c_performance_weekly_active.py` → builds `report.c2c_performance_weekly` (`snowplow_session_count`)
- `warehouse/sql/template/exporter/dynamic_content/c2c_route_enrichment.py`
- `warehouse/sql/template/exporter/dynamic_content/generate.py`
- `warehouse/sql/template/exporter/dynamic_content/generate_interlinks.py`
- `warehouse/sql/template/exporter/dynamic_content/page_link/{carrier_guide,city_guide,state_guide,country_guide}.py` (join `report.c2c_performance_weekly`)

---

## Cross-repo: `cortex` importer dependencies on Snowplow data

The **`cortex`** repo imports several mammoth warehouse tables. Snowplow data reaches cortex transitively through this flow:

```
report.snowplow_sessions
  → report.c2c_performance / report.c2c_performance_weekly   (snowplow_session_count, via seo_scoring.py)
    → mammoth export.* tables (dynamic-content exporters)
      → COPY INTO @export stage → S3 (s3://wanderu-warehouse-export/dynamic-content/…)
        → cortex importer downloads CSV.gz → cortex postgres import.* → public.*
```

Scope: `cortex/cortex/loader/importer/**` excluding `wdata/`. A cortex table is Snowplow-dependent if its mammoth source either **carries** a Snowplow-derived column (`snowplow_session_count` → often renamed `total_sessions`/`session_totals`/`month_sessions`/`sessions`) or **uses** one to rank/filter rows (even if the number isn't emitted). Both `report.c2c_performance` and `report.c2c_performance_weekly` carry `snowplow_session_count` (built by `warehouse/sql/template/transformer/seo_scoring.py`).

### Snowplow-dependent cortex importers
| cortex module | cortex table | mammoth source | mammoth producer | dep kind | how |
|---|---|---|---|---|---|
| `importer/c2c_performance_weekly_active.py` | `public.c2c_performance_weekly_active` | `export.c2c_performance_weekly_active` | `warehouse/exporter/dynamic_content/c2c_performance_weekly_active.py` | **Column** | `total_sessions = SUM(snowplow_session_count)` |
| `importer/stats/city_to_city_enrichment.py` | c2c enrichment | `export.c2c_city_enrichment` | `…/exporter/dynamic_content/c2c_route_enrichment.py` | **Column + Usage** | `session_totals = SUM(snowplow_session_count)`, emitted **and** used in route `rank()` |
| `importer/stats/state_guide_stats.py` | `public.state_guide_stats` | `export.state_guide_stats` | `…/exporter/dynamic_content/generate.py` (~L1169) | **Column + Usage** | `SUM(snowplow_session_count)`, selected + `ORDER BY … DESC` route rank |
| `importer/stats/country_guide_stats.py` | `public.country_guide_stats` | `export.country_guide_stats` | `…/exporter/dynamic_content/generate.py` (~L1540) | **Column + Usage** | `SUM(snowplow_session_count)`, selected + route rank |
| `importer/page_link.py` | `public.page_link_v2` | `export.page_link` *(note: not `page_link_v2`)* | `…/exporter/dynamic_content/page_link/{country,city,state,carrier}_guide.py` | **Usage** | link inclusion via `sessions_rank = row_number() ORDER BY SUM(snowplow_session_count)`, `WHERE sessions_rank <= 50` |
| `importer/country_guide_links/country_guide_links.py` | `public.country_guide_links` | `export.country_guide_links` | `…/exporter/dynamic_content/generate_interlinks.py` | **Usage** | links ranked/limited by `sessions = SUM(snowplow_session_count)` (`rank_order <= 60`) |
| `importer/c2c_recommended_links/load_goal.py` | recommended-links goal / PageRank graph | `report.c2c_performance_weekly` (direct read) | n/a (cortex-side SQL) | **Column + Usage** | `month_sessions = SUM(snowplow_session_count)` feeds `demand = GREATEST(month_sessions, getstat_impressions)` → `target` → the normalized **PageRank goal vector** in `build_new_link_graph.build_graph()` (`optimize_pagerank`), allocating internal-link equity across C2C pages. Also the `WHERE month_sessions > 20` inclusion gate (keeps traffic-only pages). Blended with GetStat search impressions + trip/revenue fallbacks, so degrades gracefully, not a hard dependency. |

**Blast radius:** these power C2C route enrichment, page interlinking (`page_link_v2`), country/state guide stats, and the recommended-links goal/PageRank graph — i.e. internal-linking and SEO guide content. They consume Snowplow **session counts/rankings**, not the funnel/engagement columns, so they share the fate of the SEO path in the migration health check: **robust as long as session rows keep being created** (page_view/page_ping), which nexus/canopy do emit. The risk is dilution/regional skew if new-client URL/session coverage differs, not a hard break.

### NOT Snowplow-dependent (verified)
`engagement_c2c.py` (→ `export.new_service_c2c`; the `report.c2c_performance` join is dead — no column selected/filtered), `stats/carrier_guide.py`, `stats/carrier_stat.py`, `stats/city_guide.py`, `stats/city_to_city.py`, `compressed_schedules.py`, `blog_link.py`, `blog_posts.py`, `approved_product_review_historical.py`, `translations/product_review.py`, `guide_to_variants.py`, `history_charts/{c2c_daily,c2c_monthly,c2c_days_until_depart}_avg_price.py`, `experiment_group_page.py` (reverse-direction cortex→mammoth→cortex), `new_c2c/new_c2c.py`.

### Out of mammoth scope (source is the `acq` DB, not mammoth)
`schedules_compressed.py`, `sitemap_links.py`, `alternative_transportation_mode_statistics.py` stage from `acq.seo.api_*` — not mammoth `export`/`report`. Note `sitemap_links` carries a `month_sessions` column, but it comes from `acq.seo.api_sitemap_links`, not mammoth's Snowplow tables. (If `acq.seo.*` is itself Snowplow-fed, that's a separate trace in the `acq` repo.)

---

## Cross-repo: `marketing` weekly dashboards

The **`marketing`** repo's `reporting/weekly-dashboards/queries/*.sql` query mammoth Snowflake directly (`MAMMOTH.REPORT.SNOWPLOW_SESSIONS`, `SNOWPLOW_APP_SESSIONS`, `SNOWPLOW_{IOS,ANDROID}_INSTALLS`, and `FACT_TRANSACTION.snowplow_session_key`). Split by migration exposure:

### FRAGILE — use structured-event-derived engagement columns (collapse as ui_react retires)
| query | column(s) used | fed by structured/special event |
|---|---|---|
| `funnel.sql` | `depart_result_selected`, `return_result_selected`, `tripsummary_loaded`, `booking_clicked` | search/select_result, react_tripsummary/loaded, transaction_item — **none emitted by nexus/canopy** |
| `c2c-search-did.sql`, `wc-daily-search.sql`, `c2c-per-route-summary.sql`, `wc-per-route-summary.sql` | `search_submitted` | search*/submitted |
| `c2c-nexus-kpis.sql` | `search_submitted` | search*/submitted — **already self-patched** |

> `c2c-nexus-kpis.sql` is a nexus-migration tracking scorecard that **already documents and codes around the `search_submitted` outage** (`SEARCH_CONV EXCLUDES the Apr 22–May 14 search-tracking outage … only search_submitted broke`). This is independent real-world confirmation of the migration health-check finding — the nexus cohort hit exactly the structured-event gap predicted, while landings/bookings (session rows + attribution) stayed intact.

### ROBUST — session rows / `snowplow_session_key` attribution / page-URL parsing only
`sessions.sql`, `c2c-sessions-did.sql`, `wc-daily-sessions.sql` (session counts); `cm-by-channel.sql`, `channel-gross-tickets.sql`, `email-sessions.sql`, `email-revenue.sql`, `chatgpt-sessions.sql` (channel attribution via `snowplow_session_key` + `traffic_source`/`first_page_url`); `c2c-bookings-did.sql`, `wc-daily-bookings.sql` (bookings via `snowplow_session_key` join to `fact_transaction`). `app-installs.sql` uses `snowplow_{ios,android}_installs` — mobile attribution structured events, unaffected by the *web* client migration.

### Not Snowplow (keyword false-positives)
`cart-abandonment.sql`, `reminders.sql`, `confirmations.sql`, `net-revenue.sql`, `contribution-margin.sql` — use `blueshift` + `report.fact_transaction`, no Snowplow.

---

## Cross-repo: `seo-data-transformer-pipeline` (the `acq` database)

The **`seo-data-transformer-pipeline`** repo ingests Snowplow **entirely from mammoth via Snowflake cross-domain shares** (no external collector/S3): `setup/warehouse/cross_domain_data_shares_deploy.sql` grants `mammoth.report.{events, snowplow_user_account_events, snowplow_insurance_quotes, c2c_performance_weekly}`, wrapped as `acq.import.*` views, then dbt `stg_snowplow__*` models. `stg_snowplow__events` is a `select *` passthrough, so structured-event fragility lives in the consuming models.

### FRAGILE — filter on structured events (`se_category`/`se_action`)
| model | predicate | risk |
|---|---|---|
| `dbt/.../meta_ads/intermediate/int_meta_ads__pixel_clicks.sql` | `se_category='facebook_pixel' AND se_action='loaded'` → `se_property:fbc/fbp` | breaks if web clients stop emitting the `facebook_pixel` struct event (note: `facebook_pixel` is a ui_react event) |
| `dbt/.../meta_ads/intermediate/int_meta_ads__pixel_loads.sql` | `se_category='facebook_pixel' AND se_action='loaded'` → `se_property:fbp` | same; + transitively `int_meta_ads__pixel_load_attribution.sql` → meta pixel reporting |
| `dbt/.../blueshift/final/blueshift__search_events.sql` | `se_action IN ('create_account','login')` (user-account events) → `se_property:email` | breaks if account struct events stop |

### ROBUST — canonical / attribution / session-count
- Ad-click models `int_{bing,google,meta}_ads__clicks` parse **URL query params** (`msclkid`/`gclid`/`gbraid`/`wbraid`/`fbclid`) off canonical page events — fire on any page view.
- `int_*__click_attribution` join on `snowplow_domain_id`/`snowplow_network_id` (attribution only); `int_sgtm__conversions` joins insurance quotes on `booking_session_id` (identity only).
- `seo/cms/api_sitemap_links.sql` → **`acq.seo.api_sitemap_links`**: `month_sessions = SUM(snowplow_session_count)` from mammoth `c2c_performance_weekly` (page-view-derived, robust). **This closes the loop** with the cortex `sitemap_links.py` importer noted above (its `acq.seo.*` source is Snowplow-fed here, via session counts).

### Ref chain
```
mammoth.report.events → acq.import.snowplow__events → stg_snowplow__events
   ├─ int_{bing,google,meta}_ads__clicks (URL params)      → click_attribution → rpt_*  [ROBUST]
   └─ int_meta_ads__pixel_{clicks,loads} (facebook_pixel)  → pixel reporting           [FRAGILE]
mammoth.report.snowplow_user_account_events → … → blueshift__search_events             [FRAGILE]
mammoth.report.snowplow_insurance_quotes    → … → int_sgtm__conversions                [ROBUST]
mammoth.report.c2c_performance_weekly        → … → acq.seo.api_sitemap_links            [ROBUST] → cortex sitemap_links
```

---

## Client migration health check — `report.snowplow_sessions`

**Date of analysis**: 2026-06-01 (Snowflake `mammoth.report.events`, trailing 30 days unless noted).
**Context**: New web clients **`nexus`** and **`canopy`** are replacing the legacy **`ui_react`** front end. Analytics still depend on `report.snowplow_sessions`. This section records whether that table stays reliable through the migration.

### Client (app_id) landscape — last 120 days
| app_id | platform | events | note |
|---|---|--:|---|
| `ui_react` | web | 256M | legacy web client (being retired) |
| `wanderu_ios` | mob | 68M | iOS app |
| `ui_react` | (null) | 10.8M | server-side / AMP |
| `nexus` | web | 258K | **replacement** web client |
| `US-blog` | web | 99K | blog |
| `wanderu_android` | mob | 61K | Android app |
| `canopy` | web | 3.9K | **replacement** web client (new since 2026-04-21) |
| `seo`, `turimex_android`, `search`, null | — | <10 each | negligible |

### Verdict: **HIGH RISK** as `ui_react` is retired

`snowplow_sessions` is built in `RECREATE_SESSIONS_MERGE` (`warehouse/sql/template/snowplow/materialized_tables.py`) with **no `app_id`/`platform` filter**, and the table **has no `app_id` column** (confirmed against `information_schema`). Consequences:

- **Session rows survive.** Rows are built from `page_view`/`page_ping`, which nexus/canopy both emit. So row counts, `page_view_count`/`page_ping_count`, `session_length`, geo, device, and `traffic_source` keep populating. The SEO/dynamic-content path (`materialize_analytics` → `seo_scoring` → `snowplow_session_count` → `c2c_performance_weekly` → page_link exporters) relies on session **counts** and stays largely intact.
- **Engagement/conversion columns collapse.** Every funnel column is fed only by structured events the new clients don't emit. As traffic shifts, these dilute toward zero with **no behavioral cause and no way to segment by client** (no `app_id` in the table).

### Session-critical event coverage by client — last 30 days (Q7)

> **The live panel now runs a trailing 7 days, not 30.** Figures below are the original
> 30-day snapshot and are kept for the narrative; expect the page to differ. nexus is
> ramping fast enough that the windows disagree in kind, not just degree — over the
> trailing 7 days nexus shows **163,626 `page_view`** and **119,717 `search*/submitted`**,
> the latter roughly 6× its entire prior 30-day total. Read the page for current state.
| Session event → column(s) | ui_react | nexus | canopy |
|---|--:|--:|--:|
| `page_view` → rows, page_views, traffic_source | 10.0M | 34,909 | 716 |
| `page_ping` → page_ping_count | 10.4M | 29,166 | 916 |
| `transaction_item` → booking_clicked / deeplinked | 172,572 | **0** | **0** |
| `split_test/assigned` → treatment, visitor_id | 423 | **0** | **0** |
| `search*/submitted` → search_submitted | 1.45M | 20,487 ⚠️ | **0** |
| `search*/select_result` → depart/return_result_selected | 787,128 | **0** | **0** |
| `live_check*/itinerary_completed` → livecheck_* | 898,622 | **0** | **0** |
| `react_tripsummary/loaded` → tripsummary_loaded | 850,970 | **0** | **0** |
| `checkout/click/submit|retry` → booking_form_submitted | 55,927 | **0** | **0** |
| `checkout/loaded` → booking_form_loaded | 252,102 | **0** | **0** |
| `react_search/travel_mode_*` → travel_mode_*_toggled | 128,193 | **0** | **0** |

- **canopy** emits **zero structured events** — pure page-view shells.
- **nexus** emits only search-widget events and has **no booking/results/livecheck/checkout funnel at all**.

### Mis-implementation found (nexus)
nexus's full structured inventory is 9 signatures, all search-widget. One is a **silently-dropped search submission**:

| se_category | se_action | se_label | events/30d | captured by sessions? |
|---|---|---|--:|---|
| `search_widget` | `submitted` | `valid` | 20,434 | ✅ yes |
| `c2c_search_widget` | `search_submitted` | `bus` | 5,442 | ❌ **no** — category & action match no predicate |
| `search_widget` | `focus`/`inputted` | … | ~15K | n/a (not session-critical) |
| `c2c_search_widget` | `date_selected`/`prediction_selected`/`validation_error` | `bus` | ~3.9K | n/a |

So even nexus's `search_submitted` is undercounted.

### `ui_react` one-year health — has the legacy client's tracking drifted?
From the initial trailing-year analysis (monthly grain; the reusable **Q9** monitor below now runs weekly). Two real movements and one expected stop:

1. **`split_test/assigned` dropped to ~zero ~Oct 2025 — EXPECTED, not a break.** ~3.9–4.8M/mo (May–Aug 2025) → 702K (Sep) → 2,296 (Oct) → ~434 (May 2026). This is **intentional**: wboard-powered A/B testing was discontinued, so the client stopped emitting `split_test/assigned`. Consequently `treatment_key` / `treatment_label` / `wanderu_visitor_id` in `snowplow_sessions` (and `report.treatment` / `treatment_experiment`) are empty by design from Oct 2025 onward — fine as long as no analytics still assume live treatment data. Not a tracking regression.
2. **`livecheck_completed` 10× spike Aug–Sep 2025** — ~1M/mo jumped to **~11M** for Aug & Sep 2025, then normalized to ~1M in Oct. An over-firing bug inflated `livecheck_completed*` for those two months; treat Aug–Sep 2025 livecheck metrics as unreliable.
3. **`search_submitted` ~halved over the year** — 2.95M (May 2025) → 1.48M (May 2026), **confirmed not a category rename** (a side check compared `search_widget/submitted` vs other submit categories: the signature stayed stable, no new uncaptured submit category appeared, while `search_widget/loaded` grew). A genuine behavioral/UX shift, not a tracking break.

All other session-critical streams (`page_view`, `page_ping`, `transaction_item`, `select_result`, `tripsummary_loaded`, `checkout` submit/loaded, `travel_mode_*`) were stable across the year.

### Recommended actions
1. **Clarify migration scope** — are nexus/canopy full funnel replacements or just top-of-funnel (landing/search)? If full, the tracking spec must port every session-critical signature (table above) **before** `ui_react` is retired.
2. **Fix the nexus mis-implementation** — normalize `c2c_search_widget/search_submitted` to the canonical `(search*, submitted)` shape client-side, or extend the CASE IN-lists in `RECREATE_SESSIONS_MERGE`.
3. **Add `app_id` + `name_tracker` to `snowplow_sessions`** so client mix is segmentable and dilution is detectable, not silent.
4. **Retire dead treatment columns** — `split_test/assigned` stopped intentionally (wboard A/B testing discontinued, Oct 2025). Consider dropping/deprecating `treatment_key`/`treatment_label`/`wanderu_visitor_id` and `report.treatment`/`treatment_experiment`, or document them as inactive, so consumers don't mistake the empty columns for a bug.
5. **Make the coverage matrix a recurring guardrail** — schedule Q7 / add a dbt test that alerts when an active client's coverage of any session-critical event hits zero.

### Reusable SQL

**Coverage matrix (Q7)** — session-critical event coverage by client, trailing 30 days:
```sql
WITH base AS (
  SELECT app_id, event, ti_category, se_category, se_action, se_label
  FROM   report.events
  WHERE  collector_tstamp >= DATEADD('day', -30, CURRENT_DATE())
    AND  app_id IN ('ui_react','nexus','canopy')          -- adjust client set
),
classified AS (
  SELECT app_id,
    CASE
      WHEN event='page_view' THEN '01 page_view'
      WHEN event='page_ping' THEN '02 page_ping'
      WHEN event='transaction_item' AND ti_category IN ('deeplink','wanderu_native') THEN '03 transaction_item'
      WHEN event='struct' AND se_category='split_test' AND se_action='assigned' THEN '04 split_test/assigned'
      WHEN event='struct' AND se_category IN ('search','Search Widget','search_widget','search_results') AND se_action='submitted' THEN '05 search*/submitted'
      WHEN event='struct' AND se_category IN ('search','react_search','search_results') AND se_action='select_result' THEN '06 search*/select_result'
      WHEN event='struct' AND se_category IN ('live_check','react_livecheck') AND se_action='itinerary_completed' THEN '07 live_check*/itinerary_completed'
      WHEN event='struct' AND se_category='react_tripsummary' AND se_action='loaded' THEN '08 react_tripsummary/loaded'
      WHEN event='struct' AND se_category='checkout' AND se_action='click' AND se_label IN ('submit','retry') THEN '09 checkout/click/submit'
      WHEN event='struct' AND se_category='checkout' AND se_action='loaded' THEN '10 checkout/loaded'
      WHEN event='struct' AND se_category='react_search' AND se_action='click' AND se_label LIKE 'travel_mode_%' THEN '11 react_search/travel_mode_*'
      ELSE NULL
    END AS session_event
  FROM base
)
SELECT session_event,
       COUNT_IF(app_id='ui_react') AS ui_react,
       COUNT_IF(app_id='nexus')    AS nexus,
       COUNT_IF(app_id='canopy')   AS canopy
FROM   classified WHERE session_event IS NOT NULL
GROUP  BY 1 ORDER BY 1;
```

**Per-client structured-event inventory (Q8)** — surfaces mis-implementations / variant spellings:
```sql
SELECT app_id, se_category, se_action, se_label, COUNT(*) AS events
FROM   report.events
WHERE  collector_tstamp >= DATEADD('day', -30, CURRENT_DATE())
  AND  event = 'struct'
  AND  app_id IN ('nexus','canopy')                       -- adjust client set
GROUP  BY 1,2,3,4 ORDER BY app_id, events DESC;
```

**Longitudinal stream health (Q9)** — weekly volume of each session-critical event for one client (swap `app_id` to monitor any client):
```sql
WITH base AS (
  SELECT DATE_TRUNC('week', collector_tstamp)::date AS wk,
         event, ti_category, se_category, se_action, se_label
  FROM   report.events
  WHERE  collector_tstamp >= DATEADD('week', -53, DATE_TRUNC('week', CURRENT_DATE()))
    AND  collector_tstamp <  DATE_TRUNC('week', CURRENT_DATE())   -- whole weeks only
    AND  app_id = 'ui_react'
)
SELECT wk,
  COUNT_IF(event='page_view')                                                            AS page_view,
  COUNT_IF(event='page_ping')                                                            AS page_ping,
  COUNT_IF(event='transaction_item' AND ti_category IN ('deeplink','wanderu_native'))    AS txn_item_booking,
  COUNT_IF(event='struct' AND se_category='split_test' AND se_action='assigned')         AS split_test_assigned,
  COUNT_IF(event='struct' AND se_category IN ('search','Search Widget','search_widget','search_results') AND se_action='submitted') AS search_submitted,
  COUNT_IF(event='struct' AND se_category IN ('search','react_search','search_results') AND se_action='select_result')              AS search_select_result,
  COUNT_IF(event='struct' AND se_category IN ('live_check','react_livecheck') AND se_action='itinerary_completed')                  AS livecheck_completed,
  COUNT_IF(event='struct' AND se_category='react_tripsummary' AND se_action='loaded')    AS tripsummary_loaded,
  COUNT_IF(event='struct' AND se_category='checkout' AND se_action='click' AND se_label IN ('submit','retry')) AS checkout_submit,
  COUNT_IF(event='struct' AND se_category='checkout' AND se_action='loaded')             AS checkout_loaded,
  COUNT_IF(event='struct' AND se_category='react_search' AND se_action='click' AND se_label LIKE 'travel_mode_%') AS travel_mode_toggled
FROM base GROUP BY 1 ORDER BY 1;
```

---

## Cross-system event coverage — Snowplow, Amplitude, GA4

Snowplow's structured-event funnel dilutes toward zero as `ui_react` retires. The two
systems meant to carry that load are **Amplitude** (reporting) and **GA4** (Google Ads
audiences and bidding). Neither currently covers the funnel on every platform, and the
gaps are different in each — so a stage can be missing from one, present in the other,
and invisible unless they are read side by side.

Verified 2026-08-07. Amplitude names come from the weekly-dashboard engagement-funnel
queries; GA4 names from the audiences built on 2026-08-06/07.

### The canonical funnel, and who captures it

✅ captured · ⚠️ partial or non-canonical name · ❌ absent

GA4 columns are split by platform. Both live in one property (`153892886`), which is
exactly why the breakage stayed hidden — the property-level totals look fine.

| Stage | Amp Web | Amp iOS | Amp Android | GA4 Web | GA4 iOS | GA4 Android |
|---|---|---|---|---|---|---|
| Session start | ✅ `session_start` | ✅ `session_start` | ✅ `session_start` | ✅ `session_start` | ✅ `session_start` | ❌ **~1% of expected** |
| Search | ✅ `Search Submitted` | ❌ **none** | ❌ **none** | ✅ `search_conversion` | ✅ `search` / `view_search_results` | ❌ **~1% of expected** |
| Trip selected | ✅ `Trip Selected` | ❌ **none** | ✅ `Trip Selected` *(since 2026-07-13)* | ✅ `full_trip_selected` | ❌ **none** | ❌ **none** |
| Checkout started | ✅ `Checkout Started` | ✅ `Checkout Started` | ✅ `Checkout Started` | ✅ `checkout` | ✅ `begin_checkout` | ❌ **~1% of expected** |
| Payment submitted | ✅ `Payment Submitted` | ⚠️ `checkout_payment-submit_clicked` | ✅ `Payment Submitted` | ❌ **none** | ❌ **none** | ❌ **none** |
| Purchase | ✅ `Transaction Completed` | ✅ `Transaction Completed` | ✅ `Transaction Completed` | ✅ `purchase` | ❌ **none** | ⚠️ arrives, but detached from sessions |

Read the two GA4 app columns against each other: **iOS is missing only purchase; Android
is missing everything except purchase.** They are separate problems with separate fixes,
and the single-property view averages them into a property that looks partly working.

### Amplitude

Source: `core.stg_amplitude__events_711442`, event name in `amplitude_event_type_name`,
person is `amplitude_id` (Amplitude's own identity resolution), platform in
`('Web','iOS','Android')`.

**The canonical taxonomy is Title Case** — `Search Submitted`, `Trip Selected`,
`Checkout Started`, `Payment Submitted`, `Transaction Completed` — with `session_start`
the one snake_case exception.

Two deviations to close:

- **iOS mixes taxonomies.** It emits `checkout_payment-submit_clicked` instead of
  `Payment Submitted`, and also carries `checkout_checkout_form_viewed`,
  `checkout_submit_click` and `checkout_booking_completed` alongside the canonical
  names. Where both exist they agree to within ~60 people, so the canonical name is
  preferred and the snake_case duplicates are redundant.
- **iOS pre-checkout — `Trip Selected` absent, `Search Submitted` newly landed.**
  `Trip Selected` is still absent at any volume; that remains the single largest
  measurement gap in the app. About **15,800 people start an iOS session weekly and
  ~4,200 reach checkout**, and nothing instrumented explains most of the ~11,600 in
  between.

  `Search Submitted` is no longer absent, but the fix is days old, not established:
  it **first fired 2026-08-07** (after a 12-event blip on 08-03) and is ramping —
  254 → 1,053 → 1,424/day across 08-07 → 08-09. `Payment Submitted` landed on the same
  day and follows the same curve (30 → 97 → 149/day). Both still sit *below*
  `Checkout Started` on the same platform, which no settled funnel can do, so the
  Analytics Health page flags them as under-firing. **Re-read after a full week before
  concluding either is fixed or broken** — this row is mid-ramp, not at steady state.

  This is why the coverage panels are cut to a trailing 7 days: a 30-day window blends
  "not measured yet" into "measured and near-zero" and hides a fix that has just landed.

Android gained `Trip Selected` / `Checkout Started` / `Payment Submitted` on
**2026-07-13**; weeks before that draw as gaps rather than zeros, because "not measured"
and "measured and zero" are different claims.

### GA4

Two properties, and the split matters because Google Ads audiences and Smart Bidding are
built on them.

**Web `375780955`** — complete funnel, and the volumes are coherent:

| Event | Users / 30d |
|---|---|
| `page_view` | 2,315,115 |
| `session_start` | 2,301,562 |
| `first_visit` | 2,172,506 |
| `search_conversion` | 812,720 |
| `full_trip_selected` | 317,210 |
| `checkout` | 317,189 |
| `purchase` | 44,490 |

**App `153892886`** — broken in a mirror-image pattern by platform. Against the Amplitude
funnels for the same week (w/c 2026-07-27):

| | Amplitude | GA4 | |
|---|---|---|---|
| iOS sessions | 15,796 | 17,036 | ✅ agree |
| iOS checkout started | 4,201 | 4,645 | ✅ agree |
| iOS purchased | 2,741 | **0** | ❌ absent from GA4 |
| Android sessions | 7,442 | **80** | ❌ ~99% missing |
| Android checkout started | 471 | **3** | ❌ ~99% missing |
| Android purchased | 306 | 302 | ✅ agree |

**iOS sends engagement to GA4 but no purchases; Android sends purchases but almost no
engagement.** Each platform is missing the half the other has. It also makes GA4 report
**more Android purchasers (302) than Android session starts (80)** — purchase events
arriving detached from sessions, consistent with a server-side or forwarded path rather
than the app SDK.

GA4 app event volumes by platform, trailing 7 days, for the fix list below:

| Event | GA4 iOS | GA4 Android |
|---|---|---|
| `session_start` | 17,036 | 80 |
| `search` | 12,147 | 14 |
| `view_search_results` | 12,102 | 15 |
| `begin_checkout` | 4,645 | 3 |
| `first_open` | 4,025 | 18 |
| `purchase` | **0** | 302 |

Consequence: every GA4-sourced **app** remarketing audience is empty (all read `8`, a
floor rather than membership), while the equivalent **web** audiences reached
30,000–210,000 within a day. Google Ads therefore cannot retarget app users at all, and
Smart Bidding on app campaigns has no conversion signal to learn from — which is why the
iOS `purchase` and Android engagement fixes below are ranked ahead of the rest.

### What should be tracked

Ordered by what unblocks the most.

1. **iOS `purchase` → GA4.** Amplitude records ~2,741/week and GA4 records none, so
   Google Ads cannot bid on or build audiences from the larger app platform. Highest
   value, since it feeds bidding as well as reporting.
2. **Android engagement → GA4.** `session_start` and `begin_checkout` reach GA4 at ~1%
   of Amplitude's volume. Android instrumentation exists — it landed in Amplitude
   2026-07-13 — so this is a delivery gap, not a definition gap.
3. **iOS pre-checkout → Amplitude.** `Search Submitted` and `Trip Selected` are absent
   entirely, leaving ~11,600 people a week unexplained between session and checkout.
4. **`Payment Submitted` → GA4, both properties.** Present in Amplitude on every
   platform, absent from GA4 everywhere. The cheapest remaining high-intent audience,
   and the closest pre-purchase signal for bidding.
5. **Trip-selected on GA4 App.** Web has `full_trip_selected`; the app property has no
   equivalent, so the app funnel skips from search to checkout.
6. **Normalise iOS Amplitude naming** onto the Title Case taxonomy and retire the
   snake_case duplicates, so a single query shape works across platforms.

### Why this belongs with the Snowplow migration

The Snowplow structured events dilute as `ui_react` retires, and the replacements are
uneven. **Web is safe** — Amplitude and GA4 both carry the full funnel. **App is not**:
of six funnel stages across two platforms, Amplitude is missing three and GA4 five.
Retiring Snowplow's app coverage before these land would remove the only system that
currently sees some of these stages.

---

## Maintenance / config (reference, not data dependents)

- `bin/materialize_snowplow.py` orchestrator
- `.config/dev.yaml`, `.config/prod.yaml`: `MAMMOTH_SNOWPLOW_BUCKET/STAGE`, `SNOWPLOW_ENRICHED_BUCKET`, `SNOWPLOW_BAD_ARCHIVE_BUCKET`, `SNOWPLOW_LOADER_DELAY_SECONDS`
- RBAC grants: `scripts/snowflake_rbac_setup/*.sql` (grant access to `report.events` / `report.vw_snowplow_events`)
- `warehouse/schema.py`, `warehouse/instrumentation.py`: schema/metadata definitions

---

## Caveats

1. `report.vw_snowplow_events` is a view defined in Snowflake/migrations, not in repo SQL — inferred from its usage.
2. Many `migrations/archive/redshift/**` files reference legacy Snowplow tables (Redshift era) and are excluded as historical.
3. The migration health check reflects `mammoth.report.events` as of 2026-06-01 over a trailing-30-day window (one-year section: trailing 13 whole months). Volumes will differ on re-run.
