feat: stamp is_staff on all GA4 events - #168
Conversation
Analytics cannot distinguish internal staff traffic from real users, so staff usage skews click-rate and engagement reports as the team ramps up. Route every gtag call through a single track() helper that attaches is_staff, derived from the is-moderator attribute the host already sets from Django's request.user.is_staff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📊 Code Quality Score: 10/100
Was this score accurate? 👍 Yes · 👎 No Scored by GitVelocity · How are scores calculated? |
Review of the is_staff change surfaced two defects in it.
The component read is-moderator three different ways: strict equality for
the new GA4 flag, plain truthiness for the settings gear and the Braintrust
tag. Custom-element attributes arrive uncoerced as strings, so any value
outside {true, "true"} showed staff UI and tagged the trace as staff while
reporting is_staff "false" to GA4 — the exact silent skew this branch exists
to remove. Normalize once and have all three consumers read that boolean.
Routing the response-link event through track() also hoisted new URL(raw)
out of the gtag guard. new URL throws on a malformed absolute href, so on a
host without gtag a bad assistant link would kill the click listener. Guard
it and keep the raw href, which also drops a double URL construction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Multi-agent review (4 parallel agents: code quality, Svelte 5 runes, privacy/security, test coverage)Two real defects were found in this PR and are fixed in 1.
|
| Consumer | Test |
|---|---|
GA4 is_staff (new) |
=== true || === 'true' — strict |
Settings gear (:1152) |
{#if isModerator} — truthiness |
Braintrust tag (api.js:77) |
if (isStaff) — truthiness |
For any value outside {true, "true"} — e.g. "True", "1" — the gear would show and Braintrust would tag the session as staff while GA4 recorded is_staff: "false". That is precisely the silent skew this PR exists to eliminate, and it would have shipped green.
Latent (not live) only because ReaderApp.jsx:2476 passes is-moderator={this.props.is_moderator || undefined}, so non-staff get the attribute omitted. The safety of the flag rested on an undocumented invariant in another repo.
Fix: normalize once (isModeratorBool), and have all three consumers read that boolean. Note "false" is a truthy string in JS — excluded explicitly, since treating it as staff would misreport every real user.
2. new URL(raw) was hoisted out of the gtag guard and can throw
Routing the response-link event through track() moved new URL(raw) outside typeof window.gtag === 'function'. new URL() throws on a malformed absolute href (e.g. a bare https://), so on a host without gtag (demo harness, chrome extension) a bad assistant-authored link would throw a TypeError inside the click listener and kill the handler.
Fix: try/catch, fall back to the raw href. Also removes a double new URL() construction on the same line.
Verification
Live in-browser against the dev server (gtag stubbed):
is-moderator="True"→is_staff: "true"(was"false"before the fix)- malformed link with no
gtag→ no throw - malformed link with
gtag→ still emits, raw href preserved - all 8 plausible host values (
true/"true"/"True"/"1"/""/false/"false"/absent) → gear, Braintrust, and GA4 now agree in every case
Not fixed here (pre-existing, filed separately)
context.labsis client-controlled (server/chat/V2/views.py:359). Any visitor can POSTlabs: trueand unlock Labs tools. Should be derived server-side from the session. Worth its own ticket.- The appetizer event sends the rendered topic sentence alongside the host's
user_id; topics like health/sexuality subjects put this near GA's sensitive categories rules. Analytics owner should confirm. Also, GA4 truncates event-param values at 100 chars, so long Hebrew sentences are silently cut. assistant_clicksends the same value as bothtextandlink_text(wastes one of GA4's 25 param slots).
Caveat on the green check
CI does not test this diff. There is no frontend test harness in this repo (no vitest/jest, no tests under src/); ci.yaml runs pytest only. The green "Tests" check exercises zero lines of this PR. The track() invariant is enforced by a doc sentence alone — a CI grep gate or an ESLint no-restricted-globals rule would make it real. Recommended as a follow-up.
Review dispositionsAll findings triaged. The two defects in this PR are fixed (
Documented in the Sefaria wiki runbook ( |
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… name Renaming the prop rather than the consumers keeps every call site untouched and makes the uncoerced attribute unreachable outside the normalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-verified on the full Sefaria stack (not the demo harness)Earlier verification ran against the standalone This surfaced the fact that makes the coercion fix load-bearing: the host passes
Also sent a real message through the live agent: Caveats, stated plainly:
|
|
The preview deployment for sefaria/ai-chatbot:server is ready. 🟢 Open app | Open Build Logs | Open Application Logs Last updated at: 2026-07-14 09:20:58 CET |
|
(Claude writing on Daniel's behalf)
|
dcschreiber
left a comment
There was a problem hiding this comment.
I'm approving because it seems like you anyways can't merge until you fix the conflicts, which was my one comment.
Problem
Raised in #library-assistant: our usage reports (time-to-appetizer, click rates on topic links / pin-in-location / thinking steps) can't exclude internal staff traffic. As the team ramps up its own usage, staff sessions become a meaningful share of a still-small sample and skew every rate. Michael asked whether we're skewing the results; Josh asked for a filter flag "present in all events."
Root cause
The widget already receives the staff bit —
ReaderApp.jsxpassesis-moderator, andreader/views.py:301sets it from Django'srequest.user.is_staff. It was only used for the settings gear and Braintrust metadata, and never reached GA4.Structurally, all seven
window.gtag(...)calls inLCChatbot.sveltewere inline and independent, each passing only its own params — there was no shared layer where a common property could be attached.Change
Route every event through a single
track()helper that stampsis_staffon all of them:Sent as a string because GA4 custom dimensions are text. Covers
assistant_click,assistant_element_shown, andassistant_message_sent.Convention documented in
src/CLAUDE.md: never callwindow.gtagdirectly — an event that bypassestrack()is invisible to the staff filter and will silently skew reports.Verification
Live-verified against the dev server with
gtagstubbed (not just a build check):is-moderator="true"→assistant_clickandassistant_message_sentcarryis_staff: "true"is_staff: "false"assistant_element_shownpath carries it tooFollow-ups (not in this PR)
is_staffmust be registered as an event-scoped custom dimension in the GA4 admin — until then the param is collected but not available as a report/segment filter.is_staff, so numbers already shared can't be retroactively filtered.🤖 Generated with Claude Code