"""UI events tier B: consented session rows + failed-search text Two tables, both created together because they arrived from the same pair of operator decisions (see UI-EVENTS.md §6): * ``ui_event_session`` — one row per event, carrying an ephemeral per-tab session id, so a funnel can be computed. This is a **behavioural sequence dataset**: the thing ``ui_event_hourly`` was deliberately shaped to avoid. It therefore gets the shortest retention in the system (30 days), minute-granularity timestamps rather than precise ones, and an in-session ``seq`` to carry order. Only ever written for a visitor who gave opt-in consent. * ``ui_search_miss`` — normalised zero-result search text, the only free-text field anywhere in the instrumentation, captured server-side only. Stored as a per-day count rather than a row per search, and pruned below a k-anonymity floor (see ``core/events.py`` SEARCH_MISS_MIN_COUNT) so a string one person typed once does not persist. Retention numbers live here, not in a hand-typed policy on the box, so they are reviewable in the repo and move with the code. Postgres-only: guarded to no-op on the SQLite bind (tests / CI / offline Alembic), where both sinks are inert. Revision ID: 0004_ui_event_sessions Revises: 0003_ui_events Create Date: 2026-07-23 """ from alembic import op revision = "0004_ui_event_sessions" down_revision = "0003_ui_events" branch_labels = None depends_on = None # The behavioural-sequence table. Short on purpose: a funnel question is answered # in days-to-weeks, and the derived answer should be rolled up into the anonymous # aggregate rather than kept as raw sequences. SESSION_RETENTION_DAYS = 30 # Free-text search misses. Long enough to spot a seasonal gap in the place index, # short enough to be defensible for a field a person can type anything into. SEARCH_MISS_RETENTION_DAYS = 90 def upgrade() -> None: if op.get_bind().dialect.name != "postgresql": return # SQLite fallback (tests/CI): both sinks are no-ops there. op.execute("CREATE EXTENSION IF NOT EXISTS timescaledb") # No IP column, no user-agent column, no account-id column, and no unique # constraint that would require one: there is deliberately nothing here that # could re-identify a session, and nothing to join it to. op.execute(""" CREATE TABLE ui_event_session ( ts TIMESTAMPTZ NOT NULL, session TEXT NOT NULL, seq INTEGER NOT NULL, event TEXT NOT NULL, view TEXT NOT NULL DEFAULT '', prop TEXT NOT NULL DEFAULT '', value TEXT NOT NULL DEFAULT '', referrer TEXT NOT NULL DEFAULT '' ) """) # 1-day chunks: the table is dropped a chunk at a time by the retention # policy, and every query against it is "the last N days". op.execute( "SELECT create_hypertable('ui_event_session', by_range('ts', INTERVAL '1 day'))") op.execute( f"SELECT add_retention_policy('ui_event_session', " f"INTERVAL '{SESSION_RETENTION_DAYS} days')") # The only access pattern is "walk one session in order" (funnels) and # "everything in a window" (the rollup). op.execute("CREATE INDEX ui_event_session_sid_idx ON ui_event_session (session, seq)") # Failed searches: a per-day count, never a row per search. op.execute(""" CREATE TABLE ui_search_miss ( day DATE NOT NULL, q TEXT NOT NULL, count BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (day, q) ) """) # Plain table, not a hypertable: it is tiny (bounded by distinct failed # queries per day) and the prune is a predicate on `count`, not a chunk drop, # so time-partitioning buys nothing. Retention is enforced by the same daily # job that applies the k-anonymity prune -- see core/events.py # PRUNE_SEARCH_MISS_SQL and UI-EVENTS.md. op.execute("CREATE INDEX ui_search_miss_day_idx ON ui_search_miss (day)") def downgrade() -> None: if op.get_bind().dialect.name != "postgresql": return op.execute("DROP TABLE IF EXISTS ui_search_miss") op.execute("DROP TABLE IF EXISTS ui_event_session")