thermograph/backend/core/events.py

527 lines
23 KiB
Python
Raw Permalink Normal View History

"""Product-event instrumentation: what people actually *do* in the UI.
This is the durable, dimensioned tier that sits above ``core/metrics.py``'s
since-start counters. metrics.py answers "is traffic flowing right now"; this
module answers "did anyone ever get a location, and did search work" over
weeks, across deploys.
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
**Two tiers, two flags, both OFF by default.** Everything here is inert unless
the matching flag is set, so shipping it is a no-op.
* **Tier A anonymous aggregates (``THERMOGRAPH_EVENTS``).** Hourly counts keyed
by allowlisted enums. No identifier of any kind, nothing written to the
visitor's device, so it needs no consent. This runs for *every* visitor and is
the unbiased signal.
* **Tier B consented session rows (``THERMOGRAPH_EVENT_SESSIONS``).** The same
events, additionally carrying an ephemeral per-tab session id, written one row
per event so a funnel ("of the sessions that opened the map, how many reached a
grade?") can actually be computed. **Only ever sent by a browser whose visitor
gave opt-in consent** see frontend/static/consent.js. This is a behavioural
sequence dataset, which is exactly what Tier A was designed to avoid, so it
carries a much shorter retention and extra minimisation.
Tier B depends on Tier A: with ``THERMOGRAPH_EVENTS`` off, nothing records at all.
Design constraints, in priority order:
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
1. **No stable identifier.** No cookie, no cross-session id, no fingerprint, and
never the account id see ``validate_session``. The Tier B id is minted in
the browser, lives in ``sessionStorage`` (per tab), rotates on a 30-minute idle
/ 2-hour absolute cap, and is unlinkable to any other session, device, or
account. Tier A has no id column at all.
2. **No free text, no coordinates.** Every event field is drawn from a fixed
allowlist (see ``SCHEMA``); an unrecognised name or value collapses to a
bounded "other" bucket. The one deliberate exception is the failed-search text
captured server-side by ``record_search_miss`` heavily normalised, rejected
on any personal-data smell, and pruned unless several people typed the same
thing. It is never accepted from the browser.
3. **No IP.** The caller's IP is used only as an in-memory rate-limit key for the
current minute (see ``metrics.rate_ok``) and is never passed in here, never
stored, and never joined to a session id.
4. **Never breaks a request.** Every entry point swallows its own errors.
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
Storage (Postgres; see ``alembic/versions/0003_ui_events.py`` and ``0004``):
* ``ui_event_hourly`` Tier A hourly aggregate, 400-day retention.
* ``ui_event_session`` Tier B per-event rows, **30-day** retention, minute-
granularity timestamps and an in-session sequence number instead of precise
clock times.
* ``ui_search_miss`` normalised zero-result queries, 90-day retention, with
singletons pruned at 7 days (see ``SEARCH_MISS_MIN_COUNT``).
Plus one JSONL line per event into the activity stream so Loki/Grafana can show
the live picture. Off Postgres (dev, tests, offline tooling) the SQL sinks
degrade to no-ops and only the JSONL line is written the same dialect split
``data/store.py`` and ``core/metrics.py`` already use.
"""
import datetime
import os
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
import re
import threading
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
import unicodedata
from core import audit
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
# --- feature flags -------------------------------------------------------------
# Default OFF everywhere. Turn on per-environment (dev first, then beta, then
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
# prod) once the privacy copy in frontend/templates/privacy.html.j2 matches what
# is actually recorded.
def _flag(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
#: Tier A. Nothing at all records without this.
ENABLED = _flag("THERMOGRAPH_EVENTS")
#: Tier B. Consented, session-scoped rows. Separate flag on purpose: Tier A is
#: the thing worth running permanently, and Tier B should be switchable off
#: (e.g. at the end of a time-boxed study) without losing the aggregate history.
SESSIONS_ENABLED = _flag("THERMOGRAPH_EVENT_SESSIONS")
#: Zero-result search text. Separate flag again — it is the only free-text field
#: in the system and deserves its own on/off switch and its own decision.
SEARCH_MISS_ENABLED = _flag("THERMOGRAPH_SEARCH_MISS")
DATABASE_URL = os.environ.get("THERMOGRAPH_DATABASE_URL", "").strip()
IS_POSTGRES = DATABASE_URL.startswith("postgresql")
def _libpq_dsn(url: str) -> str:
"""A plain libpq URL psycopg accepts (mirrors climate_store/metrics)."""
return url.replace("+asyncpg", "").replace("+psycopg", "")
_PG_DSN = _libpq_dsn(DATABASE_URL) if IS_POSTGRES else ""
# --- schema -------------------------------------------------------------------
# Seven events. Every one answers a product question that cannot be answered from
# the request log alone; anything the request log *can* answer (page views, API
# usage, error rates) is deliberately NOT duplicated here.
#
# Three dimension slots are stored — ``view``, ``prop``, ``value`` — plus a
# referrer domain on ``view.open`` only (acquisition), so the key space stays
# small enough to eyeball. Each event declares which slots it uses and the exact
# allowlist for each.
VIEWS = frozenset({
"home", "calendar", "day", "compare", "score", "alerts", "legend",
"city", "cities", "records", "month", "glossary", "about", "privacy",
})
#: Where a location came from. The activation question: do visitors ever get
#: past "no place picked", and by which route?
PICK_METHODS = frozenset({"geolocate", "map", "search", "restore", "link"})
#: Did the location search actually work? ``hit`` = results shown, ``typo`` =
#: results only after respelling (places.suggest's ``corrected``), ``miss`` =
#: nothing found. The query string itself is never sent or stored.
SEARCH_OUTCOMES = frozenset({"hit", "typo", "miss"})
#: In-view controls worth knowing about. Anything not listed is dropped, so a
#: new control has to be added here deliberately (and reviewed for privacy).
CONTROLS = frozenset({
"chart_metric", # value: the metric key
"unit", # value: F | C
"date", # value: today | past
"range", # value: the span bucket
"season", # value: season key or "custom"
"totals", # calendar totals mode
"dist_metric", # compare distribution metric
"comfort", # compare comfort slicer moved
"compare_add", # value: the location-count bucket after the add
"compare_remove",
"filter_sheet", # mobile filter sheet opened
"day_step", # day view prev/next
})
#: Bounded value vocabulary, shared across controls. A value outside this set is
#: stored as "other" — never verbatim.
VALUES = frozenset({
# chart / distribution metrics
"tmax", "tmin", "feels", "humid", "wind", "gust", "precip", "dry",
# units
"F", "C",
# dates
"today", "past", "future",
# spans / counts (bucketed client-side, never a raw number)
"1", "2", "3", "4", "5plus",
"1y", "2y", "5y", "10y", "max",
# seasons
"winter", "spring", "summer", "fall", "custom", "all",
# generic
"on", "off", "other",
})
SHARE_KINDS = frozenset({"link", "png", "permalink"})
#: The alerts funnel. ``ok`` records whether the step succeeded, so a high
#: start/low create ratio is visible without any per-person tracking.
ALERT_STEPS = frozenset({"open", "start", "create", "push_on", "push_off", "test"})
#: Dead ends — the highest-signal events here. A spike in any of these is a bug
#: report nobody filed.
DEADENDS = frozenset({
"grade_error", # /api/grade returned an error the user saw
"no_data", # a cell with no usable history
"geo_denied", # browser geolocation refused / timed out
"search_miss", # search returned nothing (paired with place.search=miss)
"not_found", # a 404 page was rendered
"offline", # the fetch failed with no network
})
OK = frozenset({"y", "n"})
#: name -> {slot: allowlist}. A slot absent from an event is always stored "".
SCHEMA: "dict[str, dict[str, frozenset]]" = {
# A view was opened (page load or SPA view). ``prop`` is how it was reached.
"view.open": {"view": VIEWS, "prop": frozenset({"direct", "nav", "link", "restore"})},
# A location was chosen. NEVER carries coordinates — only the route taken.
"place.pick": {"view": VIEWS, "prop": PICK_METHODS},
# A location search completed. Recorded server-side from api_suggest as well
# as client-side, so searches from every surface are covered.
"place.search": {"prop": SEARCH_OUTCOMES},
# An in-view control was used.
"view.control": {"view": VIEWS, "prop": CONTROLS, "value": VALUES},
# Share / export.
"share": {"view": VIEWS, "prop": SHARE_KINDS},
# The alerts funnel.
"alert": {"prop": ALERT_STEPS, "value": OK},
# A dead end the visitor actually hit.
"deadend": {"view": VIEWS, "prop": DEADENDS},
}
#: Anything unrecognised collapses here with every dimension blank — visible
#: enough to notice abuse, bounded enough to be harmless.
EVENT_OTHER = "other"
VALUE_OTHER = "other"
SLOTS = ("view", "prop", "value")
#: Max events per batched beacon payload. The client batches bursts (chart-metric
#: taps, slider drags) rather than firing a request each.
MAX_BATCH = 20
#: Max JSON body the beacon will read, in bytes. Must comfortably exceed
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
#: MAX_BATCH full-sized events (~120 bytes each with a session id) or the byte
#: cap, not the batch cap, becomes the binding limit and a legitimate full batch
#: is silently dropped. Still far too small to be worth abusing as storage.
MAX_BODY_BYTES = 4096
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
# --- Tier B: the ephemeral session id ------------------------------------------
# The id is minted in the browser (16 random bytes, base64url, 22 chars) and is
# the ONE field a client could otherwise use to smuggle arbitrary text into the
# pipeline — a free-text column with a different name. So it is validated by
# shape, not merely length-capped: anything that is not exactly 22 base64url
# characters is discarded and the event still records to Tier A. There is
# nothing to gain by sending a "clever" id, and nothing gets stored if you try.
_SESSION_RE = re.compile(r"\A[A-Za-z0-9_-]{22}\Z")
#: Rows per session before Tier B stops recording (Tier A keeps counting). Bounds
#: both storage and how long/detailed any one behavioural sequence can get — a
#: 200-step sequence is already far more than any funnel question needs.
MAX_SEQ = 200
def validate_session(sid) -> str:
"""A well-formed ephemeral session id, or "" — never anything else.
Deliberately NOT a sanitiser: an id that does not match the expected shape is
dropped entirely rather than trimmed into shape, because the only thing a
malformed id can be is either a bug or an attempt to use the field as a
free-text channel, and both should lose their payload completely.
The account id is never accepted here and is never read on this route: see
web/app.py's api_event, which does not touch the session cookie. Tying an
anonymous behavioural sequence to an identified person would convert this
whole dataset into personal data about a known individual, for essentially
no analytical gain (the questions are about the product, not about who).
"""
if not isinstance(sid, str):
return ""
return sid if _SESSION_RE.match(sid) else ""
def validate_seq(seq) -> int:
"""An in-session sequence number in [0, MAX_SEQ], or -1 for "not in a
session". Ordering is carried by this counter rather than by a precise
timestamp, so the stored time can stay coarse (see ``_minute_bucket``)."""
if isinstance(seq, bool) or not isinstance(seq, int):
return -1
return seq if 0 <= seq <= MAX_SEQ else -1
# --- the one free-text field: zero-result search queries ------------------------
# Captured server-side only (api_suggest), never accepted from a browser. What
# people searched for and did NOT find is the single highest-value product signal
# here — it names the gaps in the place index — but a search box is a field a
# person can type anything into, so this is the only place where personal data
# could arrive incidentally. Four layers of mitigation, in order of strength:
#
# 1. Only ZERO-RESULT queries are considered at all. A query that found a place
# is already answered by the `hit` counter and is never stored as text.
# 2. Reject (never sanitise) anything with a personal-data smell — an address,
# an email, a URL, a long digit run, a sentence. Stripping bad characters
# would keep the text; rejecting loses it, which is the point.
# 3. Store as a COUNT per normalised string per day, not as a row per search.
# 4. Prune anything only ever typed by a couple of people (see
# SEARCH_MISS_MIN_COUNT). A string three different people typed is a product
# signal; a string one person typed once is the risky case, and it is the
# one that gets deleted.
#: Longer than this is a sentence or an address, not a place name.
SEARCH_MISS_MAX_LEN = 40
#: More words than this likewise.
SEARCH_MISS_MAX_WORDS = 5
#: A run of digits this long is a phone number / ID, not a postcode.
SEARCH_MISS_MAX_DIGIT_RUN = 6
#: Rows below this count are deleted once they are older than
#: SEARCH_MISS_PRUNE_DAYS — a k-anonymity floor on the free-text field.
SEARCH_MISS_MIN_COUNT = 3
SEARCH_MISS_PRUNE_DAYS = 7
# Letters (any script), marks, digits, and the punctuation that genuinely occurs
# in place names. Anything else is a smell, not a typo.
_PLACE_OK = re.compile(r"\A[^\W_]+(?:[ \-'.,()·’][^\W_]+)*\Z", re.UNICODE)
_DIGIT_RUN = re.compile(r"\d{%d,}" % (SEARCH_MISS_MAX_DIGIT_RUN + 1))
_URLISH = re.compile(r"(://|www\.|\.[a-z]{2,}(/|\Z))", re.IGNORECASE)
def normalize_search_miss(q) -> str:
"""A failed query reduced to a storable form, or "" to store nothing.
NFKC-normalised, casefolded, whitespace-collapsed, length- and word-capped.
Returns "" meaning *drop it* for anything that smells like personal data
rather than a place name.
"""
if not isinstance(q, str):
return ""
q = unicodedata.normalize("NFKC", q).strip()
q = " ".join(q.split()) # collapse all internal whitespace runs
if not q:
return ""
# Casefold, not lower(): correct for ß/İ and friends, and it collapses the
# trivial "same query, different capitalisation" variants into one row.
q = q.casefold()
if len(q) > SEARCH_MISS_MAX_LEN:
return ""
if len(q.split(" ")) > SEARCH_MISS_MAX_WORDS:
return ""
if "@" in q or _URLISH.search(q): # email / URL / hostname
return ""
if _DIGIT_RUN.search(q): # phone number, account id, coords
return ""
if not _PLACE_OK.match(q): # punctuation soup, not a place name
return ""
return q
def normalize(name: str, props: "dict | None" = None) -> "tuple[str, dict[str, str]]":
"""An allowlisted ``(event, {view, prop, value})`` pair.
The allowlist is the whole security model of an unauthenticated write
endpoint: it is what stops the beacon from becoming unbounded storage, a
spam sink, or an accidental PII channel. Unknown event -> ``EVENT_OTHER``
with blank dimensions; unknown value in a known slot -> ``VALUE_OTHER``;
a slot the event does not declare -> dropped entirely.
"""
name = (name or "").strip()
spec = SCHEMA.get(name)
if spec is None:
return EVENT_OTHER, {s: "" for s in SLOTS}
props = props if isinstance(props, dict) else {}
out: "dict[str, str]" = {}
for slot in SLOTS:
allowed = spec.get(slot)
if allowed is None:
out[slot] = ""
continue
raw = props.get(slot)
raw = raw.strip() if isinstance(raw, str) else ""
out[slot] = raw if raw in allowed else (VALUE_OTHER if raw else "")
return name, out
# --- durable aggregate sink ---------------------------------------------------
# One row per (hour, event, view, prop, value, referrer). No row is ever written
# per interaction, so the table cannot hold a sequence of one person's actions
# even in principle.
_local = threading.local()
def _conn():
"""Thread-local autocommit psycopg connection, or None when Postgres is off
or unreachable. Mirrors data/climate_store.py."""
if not IS_POSTGRES:
return None
conn = getattr(_local, "conn", None)
if conn is not None and not conn.closed:
return conn
try:
import psycopg # local import: only the Postgres path needs it
_local.conn = psycopg.connect(_PG_DSN, autocommit=True)
except Exception: # noqa: BLE001 - degrade to "no durable sink", never raise
_local.conn = None
return _local.conn
def _hour_bucket() -> datetime.datetime:
now = datetime.datetime.now(datetime.timezone.utc)
return now.replace(minute=0, second=0, microsecond=0)
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
def _minute_bucket() -> datetime.datetime:
"""Tier B timestamps are truncated to the minute. Precise clock times are a
fingerprint (inter-event millisecond gaps are individuating, and they let two
rotated sessions be stitched back together by adjacency); the *order* of a
session's events is what a funnel needs, and that is carried by ``seq``."""
now = datetime.datetime.now(datetime.timezone.utc)
return now.replace(second=0, microsecond=0)
def _utc_day() -> datetime.date:
return datetime.datetime.now(datetime.timezone.utc).date()
_UPSERT = """
INSERT INTO ui_event_hourly (bucket, event, view, prop, value, referrer, count)
VALUES (%s, %s, %s, %s, %s, %s, 1)
ON CONFLICT (bucket, event, view, prop, value, referrer)
DO UPDATE SET count = ui_event_hourly.count + 1
"""
def _upsert(event: str, dims: "dict[str, str]", referrer: str) -> None:
conn = _conn()
if conn is None:
return
try:
conn.execute(_UPSERT, (_hour_bucket(), event, dims["view"], dims["prop"],
dims["value"], referrer))
except Exception: # noqa: BLE001 - a broken connection retires itself below
try:
conn.close()
except Exception: # noqa: BLE001
pass
_local.conn = None
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
_INSERT_SESSION = """
INSERT INTO ui_event_session (ts, session, seq, event, view, prop, value, referrer)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
"""
def _insert_session(session: str, seq: int, event: str,
dims: "dict[str, str]", referrer: str) -> None:
conn = _conn()
if conn is None:
return
try:
conn.execute(_INSERT_SESSION, (_minute_bucket(), session, seq, event,
dims["view"], dims["prop"], dims["value"], referrer))
except Exception: # noqa: BLE001 - a broken connection retires itself
try:
conn.close()
except Exception: # noqa: BLE001
pass
_local.conn = None
_UPSERT_SEARCH_MISS = """
INSERT INTO ui_search_miss (day, q, count) VALUES (%s, %s, 1)
ON CONFLICT (day, q) DO UPDATE SET count = ui_search_miss.count + 1
"""
def _upsert_search_miss(q: str) -> None:
conn = _conn()
if conn is None:
return
try:
conn.execute(_UPSERT_SEARCH_MISS, (_utc_day(), q))
except Exception: # noqa: BLE001
try:
conn.close()
except Exception: # noqa: BLE001
pass
_local.conn = None
#: Deletes the free-text rows that only one or two people ever produced, once
#: they are old enough that the count has stopped growing. Run daily (see the
#: rollout section of UI-EVENTS.md); parameterised here rather than typed by hand
#: on the box so the retention rule is reviewable in the repo.
PRUNE_SEARCH_MISS_SQL = (
"DELETE FROM ui_search_miss "
"WHERE day < CURRENT_DATE - INTERVAL '%d days' AND count < %d"
% (SEARCH_MISS_PRUNE_DAYS, SEARCH_MISS_MIN_COUNT)
)
# --- public entry points -------------------------------------------------------
def record(name: str, props: "dict | None" = None,
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
referer: "str | None" = None, host: "str | None" = None,
session: "str | None" = None, seq=None) -> None:
"""Record one product event. Best-effort, silent, and a no-op unless the
feature flag is on.
``referer``/``host`` come from the request's own headers (a client-supplied
referrer would be forgeable and buys nothing) and are reduced to a bare
domain by ``metrics.normalize_referrer`` never a full URL, which can carry
a search query or a private path. The referrer dimension is kept only for
``view.open``; every other event stores "" there, which is what keeps the
key space small.
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
``session``/``seq`` are the Tier B additions. They are only ever populated by
a browser whose visitor opted in, they are validated by shape (see
``validate_session``), and Tier A records identically whether they are
present or not so a dropped or refused session id costs the aggregate
nothing.
"""
if not ENABLED:
return
try:
# Import here, not at module scope: metrics imports cleanly on its own and
# this keeps the dependency one-directional at import time.
from core import metrics
event, dims = normalize(name, props)
referrer = metrics.normalize_referrer(referer, host) if event == "view.open" else ""
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
# Tier A first and unconditionally: the anonymous aggregate is the signal
# that must never depend on consent rates, client behaviour, or Tier B.
_upsert(event, dims, referrer)
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
sid = validate_session(session) if SESSIONS_ENABLED else ""
n = validate_seq(seq) if sid else -1
if sid and n >= 0:
_insert_session(sid, n, event, dims, referrer)
# Second sink: one structured line per event into the activity stream, so
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
# the Loki/Grafana stack can show the live picture without waiting on an
# hourly rollup. Carries the same allowlisted enums and nothing else —
# NOT the session id, which would put a behavioural sequence into a log
# stream with its own retention and its own access rules.
audit.log_activity("ui.event", {"event": event, **dims, "referrer": referrer})
except Exception: # noqa: BLE001 - instrumentation must never break a request
pass
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
def record_search_miss(q) -> None:
"""Record one zero-result search query, server-side only.
Never call this with a query that returned results, and never accept the
string from a browser: the browser sending its own search text would put it
in a request body, in the client, and in anything that logs bodies all of
which this deliberately avoids. Silently stores nothing when the query does
not survive ``normalize_search_miss``.
"""
if not (ENABLED and SEARCH_MISS_ENABLED):
return
try:
norm = normalize_search_miss(q)
if norm:
_upsert_search_miss(norm)
except Exception: # noqa: BLE001 - instrumentation must never break a request
pass