Skip to content

Fix activation analytics gaps: snapshot cron, event gating, rate limit - #142

Merged
ohong merged 1 commit into
mainfrom
oh-activation-followups
Jul 4, 2026
Merged

Fix activation analytics gaps: snapshot cron, event gating, rate limit#142
ohong merged 1 commit into
mainfrom
oh-activation-followups

Conversation

@ohong

@ohong ohong commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the functional gaps found in the post-merge review of the activation workstreams (#133#139):

  • Snapshot refresh cron (real gap): /open and the landing ticker were switched to snapshot-only reads, but nothing called refreshOpenStatsSnapshot() — the snapshot would go stale forever. Adds a daily CRON_SECRET-guarded /api/cron/refresh-open-stats route + vercel.json entry.
  • first_sync_confirmed over-firing (metric corruption): the event fired on every /api/usage/status poll for any user with usage, keyed to the latest usage row. Now fires only while the user's earliest daily_usage row is <24h old (a genuine first sync — a count gate was rejected since first pushes create up to 30 rows), with a per-user $insert_id so PostHog dedups repeat polls.
  • /api/analytics/activation hardening: rate-limited via the durable Supabase limiter (20/min per user, per client IP for anonymous), and the browser posthog-js double-capture removed — the server path is now the single canonical source for the 10 activation events (covers pre-consent users and owns anon→user identity stitching).
  • Cleanups: shared getCookieValue, dead dedup loop in right-sidebar route, after() fallback now logs errors.

Validation

  • New tests written failing-first for the gating and rate-limit fixes
  • bun --cwd apps/web test -- usage-status, activation-analytics, cron-refresh-open-stats, activation-contract, open-stats (25 tests pass)
  • bun --cwd apps/web typecheck, lint
  • Independent evaluator review: PASS (only minor non-blocking findings, logged in docs/ROADMAP.md)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a daily open-stats refresh job to keep analytics snapshots up to date.
  • Bug Fixes

    • Activation analytics submissions are now rate-limited for better reliability.
    • “First sync confirmed” is now tracked more accurately for eligible users, reducing duplicate or late events.
    • Pageview tracking is less likely to double-count the same URL.
    • Anonymous activation requests now use client IP-based throttling.

- Add daily /api/cron/refresh-open-stats (CRON_SECRET-guarded) so /open
  and the landing ticker snapshots actually refresh
- Gate first_sync_confirmed on earliest daily_usage row <24h old with a
  per-user $insert_id, instead of firing on every status poll
- Rate-limit /api/analytics/activation (20/min per user or client IP)
- Capture activation events server-side only (drop browser double-capture)
- Cleanups: shared getCookieValue, dead dedup loop, after() fallback logs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 4, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
straude Ready Ready Preview, Comment Jul 4, 2026 9:53am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds rate limiting to the activation analytics endpoint, introduces a new cron route to refresh open-stats snapshots, gates the first_sync_confirmed analytics event on earliest-usage recency, deduplicates a cookie-parsing helper, removes duplicate client-side PostHog capture, switches pageview dedupe to a ref, and updates docs/config.

Changes

Activation analytics, cron snapshot, and usage-status changes

Layer / File(s) Summary
Shared cookie parsing helper
apps/web/lib/analytics/activation.ts, apps/web/app/(auth)/callback/route.ts
Adds getCookieValue to the activation library and updates the callback route to use it instead of a local implementation.
Activation analytics rate limiting
apps/web/app/api/analytics/activation/route.ts, apps/web/__tests__/api/activation-analytics.test.ts
Adds rateLimit-based throttling keyed on user id or forwarded IP with early 429 return, and tests covering rate-limit behavior.
Cron open-stats refresh endpoint
apps/web/app/api/cron/refresh-open-stats/route.ts, apps/web/__tests__/api/cron-refresh-open-stats.test.ts, vercel.json, docs/CHANGELOG.md
Adds a GET route secured by CRON_SECRET Bearer auth that calls refreshOpenStatsSnapshot, returns snapshot fields or errors, adds a cron schedule entry, and adds tests/changelog.
First-sync confirmation gating
apps/web/app/api/usage/status/route.ts, apps/web/__tests__/api/usage-status.test.ts
Adds an earliest-daily_usage query and 24-hour window check to gate first_sync_confirmed capture, and changes the $insert_id dedupe key.
Client-side cleanup
apps/web/lib/analytics/client.ts, apps/web/components/providers/PublicAnalytics.tsx, apps/web/app/api/app/right-sidebar/route.ts, apps/web/lib/utils/after.ts, docs/CHANGELOG.md
Removes duplicate client-side PostHog capture, switches pageview dedupe to useRef, removes a redundant dedup loop in right-sidebar merge, and logs after() fallback failures.
Decisions and roadmap docs
docs/DECISIONS.md, docs/ROADMAP.md
Documents the server-side-only activation capture decision, first_sync_confirmed gating rationale, and adds follow-up roadmap items.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ActivationRoute as "/api/analytics/activation"
  participant RateLimiter as "rateLimit()"
  participant Analytics as "captureServerActivationEvent"

  Client->>ActivationRoute: POST activation event
  ActivationRoute->>ActivationRoute: derive rateLimitSubject (userId or IP)
  ActivationRoute->>RateLimiter: rateLimit("activation-analytics", subject, limits)
  RateLimiter-->>ActivationRoute: limited response or null
  alt limited
    ActivationRoute-->>Client: 429 response
  else not limited
    ActivationRoute->>Analytics: capture activation event
    ActivationRoute-->>Client: success response
  end
Loading
sequenceDiagram
  participant Vercel as "Vercel Cron"
  participant Route as "/api/cron/refresh-open-stats"
  participant Snapshot as "refreshOpenStatsSnapshot()"

  Vercel->>Route: GET with Authorization Bearer CRON_SECRET
  Route->>Route: validate Bearer token
  alt unauthorized
    Route-->>Vercel: 401 Unauthorized
  else authorized
    Route->>Snapshot: refreshOpenStatsSnapshot()
    alt success
      Snapshot-->>Route: snapshotDate, totalSpend, trackedUsers
      Route-->>Vercel: 200 ok:true
    else failure
      Snapshot-->>Route: throws error
      Route-->>Vercel: 500 error message
    end
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: snapshot cron, event gating, and activation analytics rate limiting.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch oh-activation-followups

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/app/api/cron/refresh-open-stats/route.ts`:
- Around line 14-22: The refresh-open-stats cron endpoint currently reports
success even when the snapshot persistence path fails. Update the route handler
around refreshOpenStatsSnapshot so it does not return a 200 response unless the
durable write actually succeeds; either propagate the write error or have
refreshOpenStatsSnapshot expose the write outcome and branch on it before
building the NextResponse.json success payload. Ensure the handler reflects
failures clearly instead of returning live stats from a failed snapshot write.

In `@apps/web/lib/analytics/activation.ts`:
- Around line 99-107: Guard the cookie parsing in getCookieValue so malformed
values do not throw; this helper is used by the activation and auth callback
routes with raw request cookies, and a bad value can currently surface as a 500.
Update getCookieValue to catch decodeURIComponent failures and return null
instead of propagating the exception, while keeping the existing name matching
and return behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ab453d0f-d796-4fff-a962-40e4f8c25db2

📥 Commits

Reviewing files that changed from the base of the PR and between 83439ce and 114132f.

📒 Files selected for processing (16)
  • apps/web/__tests__/api/activation-analytics.test.ts
  • apps/web/__tests__/api/cron-refresh-open-stats.test.ts
  • apps/web/__tests__/api/usage-status.test.ts
  • apps/web/app/(auth)/callback/route.ts
  • apps/web/app/api/analytics/activation/route.ts
  • apps/web/app/api/app/right-sidebar/route.ts
  • apps/web/app/api/cron/refresh-open-stats/route.ts
  • apps/web/app/api/usage/status/route.ts
  • apps/web/components/providers/PublicAnalytics.tsx
  • apps/web/lib/analytics/activation.ts
  • apps/web/lib/analytics/client.ts
  • apps/web/lib/utils/after.ts
  • docs/CHANGELOG.md
  • docs/DECISIONS.md
  • docs/ROADMAP.md
  • vercel.json

Comment on lines +14 to +22
try {
const stats = await refreshOpenStatsSnapshot();

return NextResponse.json({
ok: true,
snapshotDate: stats.snapshotDate,
totalSpend: stats.totalSpend,
trackedUsers: stats.trackedUsers,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== route.ts =="
sed -n '1,120p' apps/web/app/api/cron/refresh-open-stats/route.ts

echo
echo "== open-stats.ts relevant section =="
sed -n '380,500p' apps/web/lib/open-stats.ts

Repository: ohong/straude

Length of output: 3630


Persistence failures are reported as success. refreshOpenStatsSnapshot swallows snapshot-write errors and still returns live stats, so this endpoint responds 200 even when the durable write fails. That hides a broken persistence path from the cron job and any status-code-based alerting; surface the write outcome or rethrow on failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/api/cron/refresh-open-stats/route.ts` around lines 14 - 22, The
refresh-open-stats cron endpoint currently reports success even when the
snapshot persistence path fails. Update the route handler around
refreshOpenStatsSnapshot so it does not return a 200 response unless the durable
write actually succeeds; either propagate the write error or have
refreshOpenStatsSnapshot expose the write outcome and branch on it before
building the NextResponse.json success payload. Ensure the handler reflects
failures clearly instead of returning live stats from a failed snapshot write.

Comment on lines +99 to +107
export function getCookieValue(cookieHeader: string | null, name: string): string | null {
if (!cookieHeader) return null;
const target = `${name}=`;
const entry = cookieHeader
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(target));
return entry ? decodeURIComponent(entry.slice(target.length)) : null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and its usages.
git ls-files 'apps/web/lib/analytics/activation.ts' 'apps/web/**/*.{ts,tsx}' | sed 's#^`#FILE` #'

echo
echo '--- activation.ts (outline) ---'
ast-grep outline apps/web/lib/analytics/activation.ts --view expanded || true

echo
echo '--- getCookieValue definition ---'
rg -n -A8 -B4 'export function getCookieValue|decodeURIComponent\(' apps/web/lib/analytics/activation.ts

echo
echo '--- usages of getCookieValue ---'
rg -n -A4 -B4 'getCookieValue\(' apps/web

echo
echo '--- cookie header handling nearby callers ---'
rg -n -A8 -B6 'Cookie|cookie' apps/web/lib apps/web/app apps/web/routes apps/web/components || true

Repository: ohong/straude

Length of output: 41119


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant file in context and identify all call sites with line numbers.
wc -l apps/web/lib/analytics/activation.ts
cat -n apps/web/lib/analytics/activation.ts | sed -n '1,180p'

echo
echo '--- locate callback/activation routes ---'
rg -n -A6 -B6 'getCookieValue|activation_anon_id|callback' apps/web

Repository: ohong/straude

Length of output: 23023


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the auth callback route and the analytics route for error handling around getCookieValue.
cat -n apps/web/app/(auth)/callback/route.ts | sed -n '1,140p'
echo
cat -n apps/web/app/api/analytics/activation/route.ts | sed -n '1,150p'

echo
# JS semantics probe: malformed percent-encoding causes decodeURIComponent to throw.
node - <<'JS'
for (const value of ["%", "%E0%A4%A", "abc%zz", "straude_activation_id=%"]) {
  try {
    console.log(value, "=>", decodeURIComponent(value));
  } catch (err) {
    console.log(value, "=> THROW", err.name);
  }
}
JS

Repository: ohong/straude

Length of output: 204


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- callback route ---'
cat -n 'apps/web/app/(auth)/callback/route.ts' | sed -n '1,120p'

echo
echo '--- activation route ---'
cat -n 'apps/web/app/api/analytics/activation/route.ts' | sed -n '1,140p'

echo
echo '--- decodeURIComponent probe ---'
node - <<'JS'
for (const value of ["%", "%E0%A4%A", "abc%zz", "straude_activation_id=%"]) {
  try {
    console.log(value, "=>", decodeURIComponent(value));
  } catch (err) {
    console.log(value, "=> THROW", err.name);
  }
}
JS

Repository: ohong/straude

Length of output: 8173


Guard decodeURIComponent here
decodeURIComponent throws on malformed cookie values, and both the activation and auth callback routes pass raw request cookies into this helper. A crafted cookie like straude_activation_id=% can turn those requests into 500s. Return null on decode failure instead of throwing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/analytics/activation.ts` around lines 99 - 107, Guard the cookie
parsing in getCookieValue so malformed values do not throw; this helper is used
by the activation and auth callback routes with raw request cookies, and a bad
value can currently surface as a 500. Update getCookieValue to catch
decodeURIComponent failures and return null instead of propagating the
exception, while keeping the existing name matching and return behavior
unchanged.

@ohong
ohong merged commit 27f9505 into main Jul 4, 2026
8 checks passed
@ohong
ohong deleted the oh-activation-followups branch July 4, 2026 10:06
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