99 lines
4.1 KiB
Python
99 lines
4.1 KiB
Python
|
|
"""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")
|