Skip to content

feat(followed-countries): brief composer bias + telemetry PR C#3631

Open
koala73 wants to merge 2 commits into
feat/followed-countries-pr-bfrom
feat/followed-countries-pr-c
Open

feat(followed-countries): brief composer bias + telemetry PR C#3631
koala73 wants to merge 2 commits into
feat/followed-countries-pr-bfrom
feat/followed-countries-pr-c

Conversation

@koala73
Copy link
Copy Markdown
Owner

@koala73 koala73 commented May 9, 2026

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 to main.

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.

    • New scripts/lib/followed-countries-fetch.cjs — CJS helper that POSTs /relay/followed-countries with RELAY_SHARED_SECRET, 10s timeout, returns [] on every soft-failure path (missing env, 4xx/5xx, transport error, malformed JSON). Never throws.
    • scripts/lib/brief-compose.mjsreorderForFollowedBias() 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-user fetchFollowedCountries + free-tier clamp (tier < 1 → slice(0, 3) post-downgrade safety). Third layer of the three-layer entitlement gate.
    • NO envelope-shape changeserver/_shared/brief-render.js enforces a strict allow-list of envelope keys; adding a debug field would force a BRIEF_ENVELOPE_VERSION bump and tug at 7 reader sites. Operator visibility moved to a structured log line.
  • U11 — brief_thread_open telemetry.

    • Event: { country, followed, severity, source }.
    • Dashboard (src/components/LatestBriefPanel.ts): cover-card onclick fires per-brief-open signal (country/severity null, source='dashboard'). Measures dashboard→magazine pull-through.
    • Magazine (server/_shared/brief-render.js + api/brief/[userId]/[issueDate].ts): per-story granularity. Auth'd route fetches followedCountries via the relay (1500ms timeout, soft-fail) and passes to the renderer; each story's source-link anchor gets data-thread-open / data-country / data-severity / data-followed attributes. Inline capture-phase click listener forwards to window.umami?.track. Try/catch wrapped; never calls preventDefault.
    • Public-mirror route doesn't have a recipient identity, so data-followed always stamps 0 for those.
    • Composite-country handling ('IL / LB'): tokenize, primary = first token, followed=true if ANY token is in the watchlist. Bounded cardinality.

Test plan

  • npm run typecheck — clean
  • npm run typecheck:api — clean
  • npm run test:convex302 pass (PR C is server-side scripts + client telemetry, no Convex changes)
  • npm run test:data8035 pass (+58 new tests across U10 + U11)
  • U10: 36 tests (16 fetch helper + 20 bias):
    • Empty watchlist no-op, lift within lane, R10 critical override, free-tier clamp pass-through, synthesis.rankedStoryHashes precedence (LLM editorial truth wins), case-insensitive country match, envelope-shape invariance pinned.
    • Fetch: env-missing, 404/4xx/5xx, transport error, malformed JSON, wrong shape, non-string-entries filter, invalid userId, AbortSignal.timeout integration.
  • U11: 22 tests:
    • 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.

Sibling-consumer audit (per memory half-shipped-denominator-fix-audit-sibling-consumers)

Grep fetchUserPreferences|/relay/user-preferences across scripts /api /server /convex:

  • scripts/lib/user-context.cjs (producer — unchanged)
  • scripts/seed-digest-notifications.mjs (consumer of extractUserContext — untouched in this PR)
  • scripts/notification-relay.cjs (consumer — untouched)
  • convex/http.ts (relay endpoint definition — unchanged)

The new field doesn't transit userPreferences — separate followedCountries Convex table + dedicated /relay/followed-countries endpoint shipped in PR A. Zero impact on existing userPreferences.data readers.

Rollout

After PR A + PR B + PR C all merge:

  1. Set FOLLOWED_BIAS_MULTIPLIER=1.0 initially in prod env (zero bias) — verify telemetry shape lands cleanly.
  2. Enable bias by setting FOLLOWED_BIAS_MULTIPLIER=1.25.
  3. Watch brief_thread_open events 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.
  4. Critical-event override holds — non-followed criticals MUST still appear in the top N. Verify by sampling briefs.

Stacked-PR notes

Out-of-PR-C follow-ups

  • alertRules.countries schema field — pending; unlocks U8 R9 pre-fill in PR B.
  • countFollowers rate limit (P2 from /ce-code-review on PR A) — Vercel edge wrapper.
  • /ce-compound knowledge 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).

koala73 added 2 commits May 9, 2026 08:38
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)
@vercel
Copy link
Copy Markdown

vercel Bot commented May 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 9, 2026 4:55am

Request Review

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 9, 2026

Greptile Summary

PR C wires the followed-countries watchlist into the brief composer (U10 — reorderForFollowedBias stable tier-sort, free-tier clamp, CJS relay helper) and ships brief-thread-open telemetry to both the dashboard cover card and the hosted magazine (U11 — data-* attribute stamping on source-link anchors, inline Umami capture-phase listener, trackBriefThreadOpen in the SPA analytics service).

  • U10 bias: reorderForFollowedBias in brief-compose.mjs applies a three-key sort (lane → followed → originalIndex) that keeps critical stories critical-first (R10 hard contract) and is a no-op when the watchlist is empty or produces no matches. The CJS helper (followed-countries-fetch.cjs) mirrors the existing fetchUserPreferences pattern and collapses every failure to []. The free-tier clamp in seed-digest-notifications.mjs handles post-downgrade safety via getUserTier.
  • U11 telemetry: brief-render.js stamps each story's source-link anchor with data-thread-open / data-country / data-severity / data-followed; the inline IIFE forwards those to window.umami?.track on capture-phase click, with a try/catch so analytics failures never intercept navigation. Public-mode correctly builds an empty followedSet so data-followed is always \"0\" on the public mirror.

Confidence Score: 4/5

Safe 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

Filename Overview
scripts/lib/brief-compose.mjs Adds reorderForFollowedBias (stable tier-sort by lane/followed/originalIndex) and wires it into composeBriefFromDigestStories; logic is correct but one inline comment references a non-existent SEVERITY_LANE_MULTIPLIER constant.
scripts/lib/followed-countries-fetch.cjs New CJS fetch helper for the per-user watchlist relay; well-guarded (missing env, 4xx/5xx, timeout, malformed JSON, non-string entries all collapse to [] without throwing).
scripts/seed-digest-notifications.mjs Refactors isUserPro to extract getUserTier, adds free-tier clamp for followed-countries, and wires followed bias into per-user compose; getUserTier ends up called twice per user per cron cycle — only an extra Redis GET due to caching, but redundant.
api/brief/[userId]/[issueDate].ts Inlines an edge-compatible fetchFollowedCountriesEdge for U11 telemetry stamping and passes the result to the renderer; the relay call is awaited unconditionally on every auth'd magazine render, adding up to 1500 ms of latency even for users with no followed countries.
server/_shared/brief-render.js Adds extractIso2Tokens, data-* attribute stamping on source-link anchors, inline BRIEF_THREAD_OPEN_SCRIPT, and UMAMI_LOADER; public-mode correctly builds an empty followedSet; all user-controlled content goes through escapeHtml.
src/services/analytics.ts Registers brief-thread-open in the EVENTS allow-list, adds BriefThreadOpenProps type, and exports trackBriefThreadOpen; clean, typed, and consistent with the existing analytics pattern.

Comments Outside Diff (1)

  1. api/brief/[userId]/[issueDate].ts, line 67 (link)

    P2 Unconditional relay round-trip on every magazine render

    fetchFollowedCountriesEdge is awaited unconditionally on the hot path for every authenticated magazine render, regardless of whether the user has any followed countries. If the relay is healthy but slow, this adds up to FOLLOWED_COUNTRIES_TIMEOUT_MS (1500 ms) to every page render. Since this data is only used for data-followed attribute stamping (a purely informational telemetry detail that degrades gracefully to 0), consider wrapping with ctx?.waitUntil or lazily short-circuiting to [] when the user is known to have no watchlist entries.

Reviews (1): Last reviewed commit: "feat(telemetry): brief_thread_open event..." | Re-trigger Greptile

Comment on lines +1500 to +1510
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment on lines +484 to +488
// 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).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
// 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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant