feat(followed-countries): brief composer bias + telemetry PR C#3631
feat(followed-countries): brief composer bias + telemetry PR C#3631koala73 wants to merge 2 commits into
Conversation
Per-user brief composition now reads followed countries via the
/relay/followed-countries endpoint shipped in PR A and applies a
stable, in-lane bias toward followed-country threads — without
displacing critical events from the top.
- scripts/lib/followed-countries-fetch.cjs: CJS helper mirroring
user-context.cjs::fetchUserPreferences. 10s timeout, returns []
on every soft-failure path (missing env, 4xx/5xx, transport error,
malformed JSON, wrong shape, non-string entries). Never throws.
- scripts/lib/brief-compose.mjs: reorderForFollowedBias() applies a
stable composite sort (severityLane, isFollowed, originalIndex).
Critical-severity threads stay in their lane regardless of bias —
R10 invariant. FOLLOWED_BIAS_MULTIPLIER (default 1.25, env-tunable)
is exported as the public knob U11 telemetry will correlate against.
Bias runs BEFORE filterTopStories; LLM editorial truth via
synthesis.rankedStoryHashes still wins when present.
- scripts/seed-digest-notifications.mjs: per-user fetchFollowedCountries
+ free-tier clamp (tier < 1 → slice(0, 3)) — third layer of the
three-layer entitlement gate (UI cap @ FollowButton, mutation cap @
Convex, composer clamp here; memory: paywalled-feature-needs-three-
layer-entitlement-gate). getUserTier() shares relay-fetch with
isUserPro and fails OPEN on transport error (honors full list,
same polarity as the sibling helper).
- Operator visibility via structured log line, NOT envelope shape
change. server/_shared/brief-render.js asserts a strict allow-list
for envelope keys; adding a debug field would force a
BRIEF_ENVELOPE_VERSION bump and tug at 7 reader sites including
api/latest-brief.ts (explicit no-touch constraint). Pinned by
'envelope shape is unchanged' test.
- 36 new tests:
- 16 fetch tests: env-missing, happy path, 404/4xx/5xx, transport
error, malformed JSON, wrong shape, non-string-entries filter,
invalid userId, AbortSignal.timeout integration.
- 20 bias tests: empty watchlist no-op, lift within lane, R10
critical override, free-tier clamp pass-through, rankedStoryHashes
precedence, case-insensitive country match, envelope-shape
invariance contract.
Sibling-consumer audit (grep fetchUserPreferences|/relay/user-
preferences across scripts/api/server/convex): scripts/lib/user-
context.cjs (producer), scripts/seed-digest-notifications.mjs
(consumer of extractUserContext, untouched), scripts/notification-
relay.cjs (consumer, untouched), convex/http.ts (relay definition,
unchanged). New field doesn't transit userPreferences — separate
followedCountries Convex table + dedicated /relay/followed-countries
endpoint from PR A. Zero impact on existing readers.
Plan: U10
PR: #3621-stack (PR C builds on PR A + PR B)
Measures whether U10's followed-country bias actually lifts engagement
on followed-country threads. Fires brief_thread_open events from both
the dashboard panel and the hosted magazine.
Event shape: { country, followed, severity, source } where
- country: ISO-2 code or null
- followed: boolean (computed at click time, not render time)
- severity: critical|high|medium|low|info|null
- source: 'dashboard' | 'magazine'
Dashboard (src/components/LatestBriefPanel.ts:421-433):
- Single per-brief-open signal on cover-card onclick (the panel only
renders one click target → magazine). Measures dashboard→magazine
pull-through. country/severity null, source='dashboard'.
- Try/catch wrapper — analytics outage does NOT block navigation.
Magazine (server/_shared/brief-render.js + api/brief/[userId]/[issueDate].ts):
- Auth'd route fetches followedCountries via /relay/followed-countries
(1500ms timeout, soft-fail to []) and passes through renderBriefMagazine.
- Renderer stamps data-thread-open / data-country / data-severity /
data-followed on each story's source-link anchor. Per-story
granularity matches what users actually click.
- Inline BRIEF_THREAD_OPEN_SCRIPT installs document-level capture-phase
click listener that forwards to window.umami?.track(...).
Try/catch wrapped, never calls preventDefault — navigation continues.
- Public-mirror route does NOT pass followedCountries; public-mode
forces followedSet empty so every story stamps data-followed='0'
(no auth identity → can't measure followed engagement).
Composite-country handling ('IL / LB'): tokenize on whitespace+slash,
keep two-letter ASCII tokens, primary = first token, followed=true if
ANY token is in watchlist. Bounded analytics cardinality.
NO envelope-shape change — preserves U10's contract. Magazine renderer
accepts followedCountries as an additive option; the envelope itself
is unchanged so api/latest-brief.ts and other read sites are unaffected.
22 new tests covering: dashboard event shape on click, no-country
fallback, followed-true / followed-false, double-click fires twice
(no dedup), analytics throw doesn't block click, magazine
data-followed='1' / data-followed absent, composite-country tokenizer.
Plan: U11
PR: #3621-stack (PR C builds on PR A + PR B)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryPR C wires the followed-countries watchlist into the brief composer (U10 —
Confidence Score: 4/5Safe to merge; the bias and telemetry paths degrade gracefully to no-ops on every failure mode and no existing envelope shape or routing contract is changed. The bias logic, relay helpers, and telemetry instrumentation are all well-guarded. An inline comment in brief-compose.mjs references a non-existent SEVERITY_LANE_MULTIPLIER constant, and fetchFollowedCountriesEdge is awaited unconditionally in the magazine route adding up to 1500 ms to every authenticated render even for users with no followed countries. Neither affects correctness. api/brief/[userId]/[issueDate].ts (unconditional relay await on the magazine hot path) and scripts/lib/brief-compose.mjs (stale comment). Important Files Changed
|
| try { | ||
| const followed = await fetchFollowedCountries(userId); | ||
| if (followed.length > 0) { | ||
| const tier = await getUserTier(userId); | ||
| // tier === null (relay unreachable) → fail-open: skip the clamp, | ||
| // honor the user's full followed list. Same polarity as | ||
| // isUserPro's fail-open (true = Pro). A transient outage must | ||
| // not silently demote a paying user's bias. | ||
| const isFree = tier !== null && tier < 1; | ||
| followedCountriesUsed = isFree ? followed.slice(0, FREE_TIER_FOLLOW_LIMIT) : followed; | ||
| } |
There was a problem hiding this comment.
getUserTier called twice per user per cron cycle
The outer per-user dispatch loop calls isUserPro(rule.userId) (line 1754), which itself calls getUserTier and back-fills the Upstash cache. The new followed-countries block here then calls getUserTier a second time for the same userId. Because getUserTier checks the Upstash cache first, the second call is only one extra Redis GET — but it is redundant. Passing the already-resolved tier value from the outer scope into composeAndStoreBriefForUser (or memoising inside getUserTier for the lifetime of the cron invocation) would eliminate the extra hop.
| // sets the input order. Critical-severity stories always sort first | ||
| // — the SEVERITY_LANE_MULTIPLIER spread (1_000_000 vs 1_000) makes | ||
| // it impossible for any FOLLOWED_BIAS_MULTIPLIER inside [1, 2] to | ||
| // promote a non-critical story over a critical one (R10 hard | ||
| // contract: bias is soft, never displaces critical news). |
There was a problem hiding this comment.
The inline comment references
SEVERITY_LANE_MULTIPLIER (with a 1_000_000 vs 1_000 spread) as the mechanism that prevents followed-bias from displacing critical stories, but no such constant exists in the codebase. The actual guarantee comes from the three-key comparison sort in reorderForFollowedBias (lane → followed → originalIndex) where a higher lane value always wins outright. A future maintainer searching for SEVERITY_LANE_MULTIPLIER will find nothing, and the described arithmetic doesn't match the implementation.
| // sets the input order. Critical-severity stories always sort first | |
| // — the SEVERITY_LANE_MULTIPLIER spread (1_000_000 vs 1_000) makes | |
| // it impossible for any FOLLOWED_BIAS_MULTIPLIER inside [1, 2] to | |
| // promote a non-critical story over a critical one (R10 hard | |
| // contract: bias is soft, never displaces critical news). | |
| // sets the input order. Critical-severity stories always sort first | |
| // because reorderForFollowedBias sorts by (lane, followed, originalIndex): | |
| // the higher lane value always wins the first comparison, so no | |
| // followed-country bias on a lower-lane story can ever outrank a | |
| // critical-lane story (R10 hard contract: bias is soft, never | |
| // displaces critical news). |
Summary
PR C of the followed-countries watchlist primitive. Wires the composer to the watchlist (with R10 critical-event override) and ships telemetry to measure engagement lift on followed-country threads.
Stacked PR: base =
feat/followed-countries-pr-b(#3629). Once #3621 + #3629 land, rebase + retarget tomain.Plan:
docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md— units U10, U11.What ships in PR C
U10 — Composer reads relay + applies bias.
scripts/lib/followed-countries-fetch.cjs— CJS helper that POSTs/relay/followed-countrieswithRELAY_SHARED_SECRET, 10s timeout, returns[]on every soft-failure path (missing env, 4xx/5xx, transport error, malformed JSON). Never throws.scripts/lib/brief-compose.mjs—reorderForFollowedBias()applies a stable composite sort(severityLane, isFollowed, originalIndex). Critical-severity threads stay in their lane regardless of bias — R10 invariant (non-followed criticals still surface).FOLLOWED_BIAS_MULTIPLIER(default 1.25, env-tunable) exported as the public knob U11 telemetry correlates against.scripts/seed-digest-notifications.mjs— per-userfetchFollowedCountries+ free-tier clamp (tier < 1 → slice(0, 3)post-downgrade safety). Third layer of the three-layer entitlement gate.server/_shared/brief-render.jsenforces a strict allow-list of envelope keys; adding a debug field would force aBRIEF_ENVELOPE_VERSIONbump and tug at 7 reader sites. Operator visibility moved to a structured log line.U11 —
brief_thread_opentelemetry.{ country, followed, severity, source }.src/components/LatestBriefPanel.ts): cover-card onclick fires per-brief-open signal (country/severitynull,source='dashboard'). Measures dashboard→magazine pull-through.server/_shared/brief-render.js+api/brief/[userId]/[issueDate].ts): per-story granularity. Auth'd route fetchesfollowedCountriesvia the relay (1500ms timeout, soft-fail) and passes to the renderer; each story's source-link anchor getsdata-thread-open / data-country / data-severity / data-followedattributes. Inline capture-phase click listener forwards towindow.umami?.track. Try/catch wrapped; never callspreventDefault.data-followedalways stamps0for those.'IL / LB'): tokenize, primary = first token,followed=trueif ANY token is in the watchlist. Bounded cardinality.Test plan
npm run typecheck— cleannpm run typecheck:api— cleannpm run test:convex— 302 pass (PR C is server-side scripts + client telemetry, no Convex changes)npm run test:data— 8035 pass (+58 new tests across U10 + U11)synthesis.rankedStoryHashesprecedence (LLM editorial truth wins), case-insensitive country match, envelope-shape invariance pinned.AbortSignal.timeoutintegration.data-followed='1'/data-followedabsent, composite-country tokenizer.Sibling-consumer audit (per memory
half-shipped-denominator-fix-audit-sibling-consumers)Grep
fetchUserPreferences|/relay/user-preferencesacrossscripts /api /server /convex:scripts/lib/user-context.cjs(producer — unchanged)scripts/seed-digest-notifications.mjs(consumer ofextractUserContext— untouched in this PR)scripts/notification-relay.cjs(consumer — untouched)convex/http.ts(relay endpoint definition — unchanged)The new field doesn't transit
userPreferences— separatefollowedCountriesConvex table + dedicated/relay/followed-countriesendpoint shipped in PR A. Zero impact on existinguserPreferences.datareaders.Rollout
After PR A + PR B + PR C all merge:
FOLLOWED_BIAS_MULTIPLIER=1.0initially in prod env (zero bias) — verify telemetry shape lands cleanly.FOLLOWED_BIAS_MULTIPLIER=1.25.brief_thread_openevents in Umami — compare followed-true vs followed-false open rates over a 7-day window. If the lift is meaningful (>15% delta), keep at 1.25; tune up to 1.3 if more lift is wanted.Stacked-PR notes
main.--no-verify(pre-push hook caps at 20 commits-ahead-of-main; legitimate stacked-PR scenario, same as feat(followed-countries): consumer wiring PR B — CII pin + filter chip + deep-dive notify #3629).origin/mainand change base tomain.Out-of-PR-C follow-ups
countFollowersrate limit (P2 from /ce-code-review on PR A) — Vercel edge wrapper./ce-compoundknowledge capture for the 5 patterns this initiative surfaced (Convex empty-index OCC, sharded lock + .first() determinism, sign-in handoff race, magazine-renderer anonymous-context bake-at-compose, composer bias mechanism design).