Count the visits, and say whether anyone got anywhere
A hosted demo raises a question a local app never does: is anyone using it, and do they reach the part that matters? `/analytics` answers it — visitors, pages, referrers, countries, devices, which shared scenarios get played, turns and demo-key spend, API and turn errors, and a funnel from visited to played a turn to signed up. Not a third-party script, for reasons specific to this one. The CSP allows `script-src 'self'`, so a tracker means loosening it; adblockers eat the popular ones, which silently biases exactly the technical audience this project gets shown to; and none of them can see the measurement that actually matters here, which is a turn, not a pageview. **A visit is a write and never a read.** After the 189x egress fix it would be perverse to add a feature that reads rows per request, so counts accumulate in a process-local dict and flush every 60s as UPSERTs. Storage is a generic `(day, metric, label) -> hits` counter, so measuring something new later costs a constant rather than a migration, plus one row per visitor per day for the funnel flags. Every dashboard query is a GROUP BY returning tens of rows however much traffic sits behind it; a month reads back in a few kilobytes. The buffer's cost is that a hard restart can lose up to a minute — the flusher also runs on shutdown, and a tier that sleeps when idle sleeps on an empty buffer anyway. **The counters are anonymous; the access log beside them is not, on purpose.** A visitor is `HMAC(secret, "visitor:<user id>")` truncated to 32 chars — one-way, so `analytics_daily` and `analytics_visitor_days` cannot be joined back to `users`, and keyed, so no client can compute one. Story content never reaches that module, and the only content it ever names is a seeded public scenario's title; a player's own titles are theirs. `accesslog.py` is the identifying half and is a separate module writing a separate table so that separation is a property of the code rather than a convention: `access_events` records sessions, sign-ins, registrations and failed attempts with address, email and device, read on a second tab of the same page behind the same gate. Both halves are gated on `AIDND_ANALYTICS_EMAILS`, not `POWER_USERS`. An unmetered tester is not automatically someone who should see the traffic. The route 404s and the nav link is absent for everyone else, the same treatment AI Chat gets; unset in a hosted deploy means nobody sees it, including me. Three things came out of building it that a test would not have suggested. **A failed turn is an HTTP 200 with a bad ending.** The status-code middleware cannot see one, so a demo whose model had started refusing every request would look perfectly healthy from outside. All five SSE error paths in `_generate_turn` now go through a `turn_error()` helper that counts on the way out. Error buckets elsewhere are labelled by the matched route template rather than the requested path — one bucket per endpoint instead of one per adventure id, and, the reason it isn't merely tidier, an unmatched path is entirely attacker-chosen, so labelling by it would let anyone mint rows. **The funnel counts people, not clicks.** A player who starts six adventures is one person who started an adventure. That is the whole reason the per-visitor-day table exists; its flags only ever turn on, and `is_new` is settled by the first write of a visitor's first day. **The tests run on SQLite and production is Neon.** A flush that raises is caught and logged, so a dialect mistake in the UPSERTs would have stayed invisible until the dashboard quietly never filled. `test_the_upserts_compile_for_postgres` compiles both statements against the Postgres dialect without connecting to one. Two things this leans on elsewhere. `limits._client_ip` is now public `client_ip`: the access log needs the same answer, and two functions both deciding which hop is the caller's is how one of them ends up trusting a header it shouldn't. And the cleanup sweeper now starts if *either* job has work — a deployment can keep every guest forever and still want its visitor-day rows aged out. No migration. Both tables are new and `bootstrap()` calls `create_all` on existing databases too, the route `branches` took in Phase 14, so `LATEST_VERSION` is still 64. 497 tests green, frontend lint and build clean, driven by hand against a synthetic 90-day fixture at 1568px. The narrow-screen layout follows the existing 720px block but is unverified: `resize_window` is ignored on a maximized Chrome and `frame-ancestors 'none'` rules out checking it in a sized iframe. Also repaired here: a rename in test_ratelimit_hardening.py had run through the test names themselves, leaving `testclient_ip_*` — still collected by pytest, which is why it passed unnoticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfMCsN1KBLsTqMkj5hSgrY
This commit is contained in:
co-authored by
Claude Opus 5
parent
3b9e6b3d50
commit
041f9e25f3
@@ -13,6 +13,13 @@
|
||||
--accent-glow: rgba(212, 169, 78, 0.25);
|
||||
--danger: #d06565;
|
||||
--player: #9fc7d1;
|
||||
/* Chart hues (analytics dashboard). Deeper and more saturated than --accent
|
||||
and --player, which are tuned for text and borders and turn muddy once
|
||||
they are a 10px bar: these are checked against --bg-panel for lightness,
|
||||
chroma, contrast and colour-blind separation as a set. */
|
||||
--chart-1: #b58a30;
|
||||
--chart-2: #3d8ac4;
|
||||
--chart-3: #a8608f;
|
||||
--font-display: 'Cinzel', Georgia, serif;
|
||||
--font-story: 'Crimson Pro', Georgia, 'Times New Roman', serif;
|
||||
--font-ui: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||
@@ -2333,6 +2340,293 @@ button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: aut
|
||||
.card.tome:hover { transform: none; }
|
||||
}
|
||||
|
||||
/* ---------- Visit analytics (owner dashboard) ---------- */
|
||||
|
||||
/* Wider than the reading pages: this one is a grid of small figures, not
|
||||
prose, and 960px puts two charts and eight tiles into a column. */
|
||||
.an-page { max-width: 1180px; }
|
||||
.an-ranges { display: flex; gap: 6px; }
|
||||
|
||||
/* Four across, not auto-fit: there are eight tiles, and letting them flow
|
||||
leaves a single orphan on the second row at most widths. */
|
||||
.an-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.an-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
.an-tile {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.an-tile-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.1;
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
.an-tile-label { font-size: 0.82rem; color: var(--text); margin-top: 4px; }
|
||||
.an-tile-hint { font-size: 0.72rem; color: var(--text-dim); margin-top: 2px; }
|
||||
|
||||
.an-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.an-card {
|
||||
position: relative;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.an-grid .an-card { margin-bottom: 0; }
|
||||
.an-card-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.an-card-head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent);
|
||||
}
|
||||
.an-note { font-size: 0.72rem; color: var(--text-dim); }
|
||||
.an-empty { color: var(--text-dim); font-style: italic; padding: 18px 0; text-align: center; }
|
||||
|
||||
.an-legend { display: flex; gap: 12px; font-size: 0.72rem; color: var(--text-dim); }
|
||||
.an-legend-item { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.an-swatch { width: 9px; height: 9px; border-radius: 2px; display: inline-block; flex: none; }
|
||||
|
||||
/* ----- Day columns ----- */
|
||||
|
||||
.an-plot { position: relative; height: 170px; margin-top: 12px; }
|
||||
.an-gridline {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top: 1px dashed var(--border);
|
||||
pointer-events: none;
|
||||
}
|
||||
.an-gridline span {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -0.62em;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
background: var(--bg-panel);
|
||||
padding: 0 4px;
|
||||
/* The dashed rule belongs behind the bars; its value does not. Columns are
|
||||
positioned, so without this the tallest bar swallows the label. */
|
||||
z-index: 3;
|
||||
}
|
||||
.an-columns { display: flex; align-items: flex-end; gap: 2px; height: 100%; }
|
||||
.an-column {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
border-radius: 4px 4px 0 0;
|
||||
outline: none;
|
||||
}
|
||||
.an-column.hot { background: rgba(212, 169, 78, 0.07); }
|
||||
.an-stack {
|
||||
width: 100%;
|
||||
/* Capped so a 7-day range draws bars and not slabs; the column around it
|
||||
stays full width, so the hover target does not shrink with the mark. */
|
||||
max-width: 44px;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
/* A 2px gap of surface between stacked segments: the boundary reads as a
|
||||
boundary without a border, which would eat a thin bar entirely. */
|
||||
gap: 2px;
|
||||
}
|
||||
.an-bar { width: 100%; transition: filter 0.15s; }
|
||||
.an-stack > .an-bar:first-child { border-radius: 4px 4px 0 0; }
|
||||
.an-column.hot .an-bar { filter: brightness(1.2); }
|
||||
.an-tip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-bright);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.55);
|
||||
font-size: 0.74rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
.an-tip strong { font-weight: 600; color: var(--accent-bright); }
|
||||
.an-tip span { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); }
|
||||
|
||||
.an-axis { display: flex; gap: 2px; margin-top: 7px; }
|
||||
.an-axis span {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.an-axis span:first-child { text-align: left; }
|
||||
.an-axis span:last-child { text-align: right; }
|
||||
|
||||
.an-overlay-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
font-size: 0.85rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ----- Funnel ----- */
|
||||
|
||||
.an-funnel { display: flex; flex-direction: column; gap: 10px; }
|
||||
.an-funnel-row {
|
||||
display: grid;
|
||||
grid-template-columns: 168px 1fr 96px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.an-funnel-label { font-size: 0.84rem; }
|
||||
.an-funnel-track { height: 14px; background: var(--bg-input); border-radius: 7px; overflow: hidden; }
|
||||
.an-funnel-bar {
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
background: var(--chart-1);
|
||||
border-radius: 7px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.an-funnel-value {
|
||||
text-align: right;
|
||||
font-size: 0.86rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.an-funnel-share { font-size: 0.72rem; color: var(--text-dim); }
|
||||
|
||||
/* ----- Ranked lists ----- */
|
||||
|
||||
.an-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.an-list li {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
/* The bar sits behind the row rather than beside it, so a long label keeps the
|
||||
full width and the magnitude is still visible at a glance. */
|
||||
.an-list-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: rgba(181, 138, 48, 0.2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.an-list-label {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.an-list-value { position: relative; color: var(--text-dim); flex: none; }
|
||||
|
||||
/* ----- Tabs, and the access log table ----- */
|
||||
|
||||
.an-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
|
||||
.an-tabs button {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
padding: 8px 14px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.an-tabs button:hover { color: var(--text); }
|
||||
.an-tabs button.active { color: var(--accent-bright); border-bottom-color: var(--accent); }
|
||||
|
||||
.an-log-head { flex-wrap: wrap; row-gap: 10px; }
|
||||
.an-log-search { max-width: 260px; }
|
||||
|
||||
/* The table is the one thing here with a minimum width — six columns of real
|
||||
data don't compress. It scrolls inside its own box so the page never does. */
|
||||
.an-table-scroll { overflow-x: auto; }
|
||||
.an-table { width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.82rem; }
|
||||
.an-table th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
padding: 0 10px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.an-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.an-table tr:last-child td { border-bottom: none; }
|
||||
.an-table tbody tr:hover { background: rgba(212, 169, 78, 0.05); }
|
||||
/* A failed attempt is the row you are scanning for; it gets the only colour in
|
||||
the table, on a border rather than the text, which stays readable. */
|
||||
.an-table tr.failed td:first-child { box-shadow: inset 2px 0 var(--danger); }
|
||||
.an-table tr.failed td:nth-child(3) { color: var(--danger); }
|
||||
.an-cell-dim { color: var(--text-dim); }
|
||||
.an-cell-mono { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; }
|
||||
.an-tag {
|
||||
margin-left: 8px;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--border-bright);
|
||||
border-radius: 10px;
|
||||
font-size: 0.66rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.an-more { display: flex; justify-content: center; padding-top: 14px; }
|
||||
|
||||
.an-footnote {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.6;
|
||||
margin: 22px 0 0;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Mobile / narrow screens (≤ 720px)
|
||||
The desktop Play screen is a horizontal row of up to four columns
|
||||
@@ -2536,4 +2830,19 @@ button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: aut
|
||||
.branch-map-canvas { padding: 4px 6px 8px; }
|
||||
.branch-map-detail { padding: 12px 16px 16px; }
|
||||
.branch-map-hint { padding: 0 16px 10px; }
|
||||
|
||||
/* ---------- Analytics dashboard ---------- */
|
||||
.an-page .page-header { flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||
.an-tiles { gap: 8px; }
|
||||
.an-tile { padding: 10px 12px; }
|
||||
.an-tile-value { font-size: 1.35rem; }
|
||||
.an-grid { grid-template-columns: 1fr; }
|
||||
.an-plot { height: 140px; }
|
||||
.an-tabs button { padding: 8px 10px; font-size: 0.78rem; }
|
||||
.an-log-search { max-width: none; width: 100%; }
|
||||
/* Three columns don't fit; the label goes above its own bar instead. */
|
||||
.an-funnel-row { grid-template-columns: 1fr auto; gap: 4px 10px; }
|
||||
.an-funnel-track { grid-column: 1; }
|
||||
.an-funnel-label { grid-column: 1 / -1; }
|
||||
.an-funnel-value { grid-column: 2; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user