diff --git a/apps/web/__tests__/api/activation-analytics.test.ts b/apps/web/__tests__/api/activation-analytics.test.ts index 63442712..82f5cea5 100644 --- a/apps/web/__tests__/api/activation-analytics.test.ts +++ b/apps/web/__tests__/api/activation-analytics.test.ts @@ -9,9 +9,14 @@ vi.mock("@/lib/analytics/server", () => ({ identifyServerActivationUser: vi.fn().mockResolvedValue(true), })); +vi.mock("@/lib/rate-limit", () => ({ + rateLimit: vi.fn().mockResolvedValue(null), +})); + import { POST } from "@/app/api/analytics/activation/route"; import { ACTIVATION_ANONYMOUS_COOKIE } from "@/lib/analytics/activation"; import { captureServerActivationEvent, identifyServerActivationUser } from "@/lib/analytics/server"; +import { rateLimit } from "@/lib/rate-limit"; import { createClient } from "@/lib/supabase/server"; function mockAuthUser(userId: string | null) { @@ -24,11 +29,18 @@ function mockAuthUser(userId: string | null) { } as any); } -function request(body: unknown, cookie?: string) { +function request( + body: unknown, + options?: string | { cookie?: string; headers?: Record }, +) { + const cookie = typeof options === "string" ? options : options?.cookie; + const extraHeaders = typeof options === "string" ? {} : (options?.headers ?? {}); + return new Request("http://localhost/api/analytics/activation", { method: "POST", headers: { "Content-Type": "application/json", + ...extraHeaders, ...(cookie ? { cookie } : {}), }, body: JSON.stringify(body), @@ -38,6 +50,7 @@ function request(body: unknown, cookie?: string) { describe("POST /api/analytics/activation", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(rateLimit).mockResolvedValue(null); mockAuthUser(null); }); @@ -107,6 +120,67 @@ describe("POST /api/analytics/activation", () => { expect(res.status).toBe(400); expect(json.error).toBe("Invalid activation event"); + expect(rateLimit).not.toHaveBeenCalled(); expect(captureServerActivationEvent).not.toHaveBeenCalled(); }); + + it("returns 429 for rate-limited requests without capturing", async () => { + mockAuthUser("user-1"); + vi.mocked(rateLimit).mockResolvedValue( + new Response(JSON.stringify({ error: "Too many requests" }), { + status: 429, + headers: { "Content-Type": "application/json" }, + }) as any, + ); + + const res = await POST(request({ + event: "sync_command_copied", + properties: { surface: "onboarding" }, + })); + const json = await res.json(); + + expect(res.status).toBe(429); + expect(json.error).toBe("Too many requests"); + expect(rateLimit).toHaveBeenCalledWith( + "activation-analytics", + "user-1", + { limit: 20, windowSeconds: 60 }, + ); + expect(captureServerActivationEvent).not.toHaveBeenCalled(); + }); + + it("rate limits authenticated requests by user id", async () => { + mockAuthUser("user-1"); + + const res = await POST(request({ + event: "sync_command_copied", + properties: { surface: "onboarding" }, + })); + + expect(res.status).toBe(200); + expect(rateLimit).toHaveBeenCalledWith( + "activation-analytics", + "user-1", + { limit: 20, windowSeconds: 60 }, + ); + expect(captureServerActivationEvent).toHaveBeenCalled(); + }); + + it("rate limits anonymous requests by the first forwarded IP", async () => { + const res = await POST(request( + { + event: "signup_started", + properties: { surface: "signup" }, + }, + { headers: { "x-forwarded-for": "203.0.113.7, 198.51.100.4" } }, + )); + + expect(res.status).toBe(200); + expect(rateLimit).toHaveBeenCalledWith( + "activation-analytics", + "203.0.113.7", + { limit: 20, windowSeconds: 60 }, + ); + expect(captureServerActivationEvent).toHaveBeenCalled(); + }); }); diff --git a/apps/web/__tests__/api/cron-refresh-open-stats.test.ts b/apps/web/__tests__/api/cron-refresh-open-stats.test.ts new file mode 100644 index 00000000..166e02a3 --- /dev/null +++ b/apps/web/__tests__/api/cron-refresh-open-stats.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/open-stats", () => ({ + refreshOpenStatsSnapshot: vi.fn(), +})); + +import { NextRequest } from "next/server"; +import { GET } from "@/app/api/cron/refresh-open-stats/route"; +import { refreshOpenStatsSnapshot } from "@/lib/open-stats"; + +function request(token?: string) { + return new NextRequest( + new URL("/api/cron/refresh-open-stats", "http://localhost"), + { + method: "GET", + headers: token ? { authorization: `Bearer ${token}` } : undefined, + }, + ); +} + +describe("GET /api/cron/refresh-open-stats", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("CRON_SECRET", "cron-secret"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns 401 without a bearer token", async () => { + const res = await GET(request()); + const json = await res.json(); + + expect(res.status).toBe(401); + expect(json).toEqual({ error: "Unauthorized" }); + expect(refreshOpenStatsSnapshot).not.toHaveBeenCalled(); + }); + + it("returns 401 with the wrong bearer token", async () => { + const res = await GET(request("wrong-secret")); + const json = await res.json(); + + expect(res.status).toBe(401); + expect(json).toEqual({ error: "Unauthorized" }); + expect(refreshOpenStatsSnapshot).not.toHaveBeenCalled(); + }); + + it("refreshes and returns the persisted snapshot summary", async () => { + vi.mocked(refreshOpenStatsSnapshot).mockResolvedValue({ + snapshotDate: "2026-07-04", + totalSpend: 123.45, + trackedUsers: 12, + } as any); + + const res = await GET(request("cron-secret")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(refreshOpenStatsSnapshot).toHaveBeenCalledTimes(1); + expect(json).toEqual({ + ok: true, + snapshotDate: "2026-07-04", + totalSpend: 123.45, + trackedUsers: 12, + }); + }); + + it("returns 500 and logs when the refresh throws", async () => { + const error = new Error("refresh failed"); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + vi.mocked(refreshOpenStatsSnapshot).mockRejectedValue(error); + + const res = await GET(request("cron-secret")); + const json = await res.json(); + + expect(res.status).toBe(500); + expect(json).toEqual({ error: "refresh failed" }); + expect(consoleError).toHaveBeenCalledWith( + "refresh open stats snapshot failed:", + error, + ); + + consoleError.mockRestore(); + }); +}); diff --git a/apps/web/__tests__/api/usage-status.test.ts b/apps/web/__tests__/api/usage-status.test.ts index b72c94ed..fce7411b 100644 --- a/apps/web/__tests__/api/usage-status.test.ts +++ b/apps/web/__tests__/api/usage-status.test.ts @@ -14,11 +14,13 @@ import { createClient } from "@/lib/supabase/server"; function mockUsageStatus({ latestUsage, + earliestUsage = null, totals = { total_cost: 0, total_tokens: 0 }, latestPost = null, latestUsageError = null, }: { latestUsage: unknown; + earliestUsage?: unknown; totals?: unknown; latestPost?: unknown; latestUsageError?: unknown; @@ -33,6 +35,16 @@ function mockUsageStatus({ error: latestUsageError, }), }; + const earliestUsageChain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ + data: earliestUsage, + error: null, + }), + }; const latestPostChain = { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), @@ -47,6 +59,7 @@ function mockUsageStatus({ error: null, }), }; + const dailyUsageChains = [latestUsageChain, earliestUsageChain]; vi.mocked(createClient).mockResolvedValue({ auth: { getUser: vi.fn().mockResolvedValue({ @@ -54,14 +67,20 @@ function mockUsageStatus({ }), }, from: vi.fn((table: string) => { - if (table === "daily_usage") return latestUsageChain; + if (table === "daily_usage") { + return dailyUsageChains.shift() ?? latestUsageChain; + } if (table === "posts") return latestPostChain; throw new Error(`Unexpected table ${table}`); }), rpc: vi.fn(() => rpcChain), } as any); - return { latestUsageChain, latestPostChain, rpcChain }; + return { latestUsageChain, earliestUsageChain, latestPostChain, rpcChain }; +} + +function hoursAgoIso(hours: number) { + return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); } describe("GET /api/usage/status", () => { @@ -81,6 +100,45 @@ describe("GET /api/usage/status", () => { expect(captureServerActivationEvent).not.toHaveBeenCalled(); }); + it("does not capture first sync confirmation when the first usage row is older than 24 hours", async () => { + mockUsageStatus({ + latestUsage: { + id: "usage-latest", + date: "2026-07-02", + created_at: hoursAgoIso(1), + cost_usd: 1.25, + total_tokens: 2500, + output_tokens: 1200, + session_count: 2, + models: ["claude-sonnet-4-5-20250929"], + }, + earliestUsage: { created_at: hoursAgoIso(24 * 30) }, + totals: { + total_cost: 7.75, + total_tokens: 9000, + }, + latestPost: { id: "post-1" }, + }); + + const res = await GET(); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json).toEqual({ + has_data: true, + has_usage: true, + cost_usd: 7.75, + total_tokens: 9000, + session_count: 2, + top_model: "claude-sonnet-4-5-20250929", + latest_usage_id: "usage-latest", + latest_usage_at: expect.any(String), + latest_usage_date: "2026-07-02", + latest_post_url: "/post/post-1", + }); + expect(captureServerActivationEvent).not.toHaveBeenCalled(); + }); + it("captures first sync confirmation when web observes usage", async () => { mockUsageStatus({ latestUsage: { @@ -93,6 +151,7 @@ describe("GET /api/usage/status", () => { session_count: 2, models: ["claude-sonnet-4-5-20250929"], }, + earliestUsage: { created_at: hoursAgoIso(1) }, totals: { total_cost: 7.75, total_tokens: 9000, @@ -122,7 +181,7 @@ describe("GET /api/usage/status", () => { session_count: 2, total_tokens: 9000, total_cost_usd: 7.75, - "$insert_id": "first_sync_confirmed:user-1:usage-1", + "$insert_id": "first_sync_confirmed:user-1", }), }); }); diff --git a/apps/web/app/(auth)/callback/route.ts b/apps/web/app/(auth)/callback/route.ts index 01886726..ed6be60d 100644 --- a/apps/web/app/(auth)/callback/route.ts +++ b/apps/web/app/(auth)/callback/route.ts @@ -1,20 +1,10 @@ import { NextResponse } from "next/server"; import { after } from "@/lib/utils/after"; -import { ACTIVATION_ANONYMOUS_COOKIE, deriveActivationState } from "@/lib/analytics/activation"; +import { ACTIVATION_ANONYMOUS_COOKIE, deriveActivationState, getCookieValue } from "@/lib/analytics/activation"; import { captureServerActivationEvent, identifyServerActivationUser } from "@/lib/analytics/server"; import { createClient } from "@/lib/supabase/server"; import { getServiceClient } from "@/lib/supabase/service"; -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; -} - export async function GET(request: Request) { const { searchParams, origin: requestOrigin } = new URL(request.url); const origin = requestOrigin; diff --git a/apps/web/app/api/analytics/activation/route.ts b/apps/web/app/api/analytics/activation/route.ts index 9435fccc..12add8b9 100644 --- a/apps/web/app/api/analytics/activation/route.ts +++ b/apps/web/app/api/analytics/activation/route.ts @@ -4,10 +4,12 @@ import { ACTIVATION_ANONYMOUS_COOKIE, ACTIVATION_ANONYMOUS_COOKIE_MAX_AGE, deriveActivationState, + getCookieValue, isActivationEventName, sanitizeActivationProperties, } from "@/lib/analytics/activation"; import { captureServerActivationEvent, identifyServerActivationUser } from "@/lib/analytics/server"; +import { rateLimit } from "@/lib/rate-limit"; import { createClient } from "@/lib/supabase/server"; const CLIENT_EVENT_ALLOWLIST = new Set([ @@ -20,16 +22,6 @@ const CLIENT_EVENT_ALLOWLIST = new Set([ "activation_completed", ]); -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; -} - async function getUserId(): Promise { try { const supabase = await createClient(); @@ -42,6 +34,13 @@ async function getUserId(): Promise { } } +function rateLimitSubject(request: Request, userId: string | null): string { + if (userId) return userId; + + const forwardedFor = request.headers.get("x-forwarded-for"); + return forwardedFor?.split(",")[0]?.trim() || "unknown"; +} + export async function POST(request: Request) { let body: { event?: unknown; properties?: unknown }; try { @@ -55,6 +54,13 @@ export async function POST(request: Request) { } const userId = await getUserId(); + const limited = await rateLimit( + "activation-analytics", + rateLimitSubject(request, userId), + { limit: 20, windowSeconds: 60 }, + ); + if (limited) return limited; + const cookieHeader = request.headers.get("cookie"); const existingAnonymousId = getCookieValue(cookieHeader, ACTIVATION_ANONYMOUS_COOKIE); const anonymousId = existingAnonymousId ?? randomUUID(); diff --git a/apps/web/app/api/app/right-sidebar/route.ts b/apps/web/app/api/app/right-sidebar/route.ts index 5525f064..d2ba89f0 100644 --- a/apps/web/app/api/app/right-sidebar/route.ts +++ b/apps/web/app/api/app/right-sidebar/route.ts @@ -85,10 +85,7 @@ export async function GET() { } } - const merged: RightSidebarSuggestedUser[] = []; - for (const candidate of activeUsers) { - if (!merged.some((item) => item.id === candidate.id)) merged.push(candidate); - } + const merged: RightSidebarSuggestedUser[] = [...activeUsers]; for (const candidate of newSignups ?? []) { if (!candidate.username) continue; diff --git a/apps/web/app/api/cron/refresh-open-stats/route.ts b/apps/web/app/api/cron/refresh-open-stats/route.ts new file mode 100644 index 00000000..683a2054 --- /dev/null +++ b/apps/web/app/api/cron/refresh-open-stats/route.ts @@ -0,0 +1,30 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { refreshOpenStatsSnapshot } from "@/lib/open-stats"; + +export const maxDuration = 60; + +export async function GET(request: NextRequest) { + const authHeader = request.headers.get("authorization"); + const expected = process.env.CRON_SECRET; + + if (!expected || authHeader !== `Bearer ${expected}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const stats = await refreshOpenStatsSnapshot(); + + return NextResponse.json({ + ok: true, + snapshotDate: stats.snapshotDate, + totalSpend: stats.totalSpend, + trackedUsers: stats.trackedUsers, + }); + } catch (error) { + console.error("refresh open stats snapshot failed:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/usage/status/route.ts b/apps/web/app/api/usage/status/route.ts index 96cd4277..2630f475 100644 --- a/apps/web/app/api/usage/status/route.ts +++ b/apps/web/app/api/usage/status/route.ts @@ -18,11 +18,27 @@ type UsageTotalsRow = { total_tokens: number | string | null; }; +type EarliestUsageRow = { + created_at: string | null; +}; + +const FIRST_SYNC_CONFIRMATION_WINDOW_MS = 24 * 60 * 60 * 1000; + function firstModel(models: unknown): string | null { if (!Array.isArray(models) || models.length === 0) return null; return typeof models[0] === "string" ? models[0] : null; } +function isWithinFirstSyncConfirmationWindow(createdAt: string | null | undefined) { + if (!createdAt) return false; + + const createdAtMs = new Date(createdAt).getTime(); + if (!Number.isFinite(createdAtMs)) return false; + + const ageMs = Date.now() - createdAtMs; + return ageMs >= 0 && ageMs < FIRST_SYNC_CONFIRMATION_WINDOW_MS; +} + export async function GET() { const supabase = await createClient(); const { @@ -33,7 +49,7 @@ export async function GET() { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const [latestUsageResult, usageTotalsResult] = await Promise.all([ + const [latestUsageResult, usageTotalsResult, earliestUsageResult] = await Promise.all([ supabase .from("daily_usage") .select("id,date,cost_usd,total_tokens,session_count,models,created_at") @@ -44,6 +60,13 @@ export async function GET() { supabase .rpc("get_user_usage_totals", { p_user_id: user.id }) .single(), + supabase + .from("daily_usage") + .select("created_at") + .eq("user_id", user.id) + .order("created_at", { ascending: true }) + .limit(1) + .maybeSingle(), ]); if (latestUsageResult.error) { @@ -71,19 +94,25 @@ export async function GET() { .eq("daily_usage_id", latestUsage.id) .maybeSingle(); - after(() => captureServerActivationEvent({ - event: "first_sync_confirmed", - distinctId: user.id, - properties: { - surface: "usage_status", - activation_state: "activated", - is_authenticated: true, - session_count, - total_tokens, - total_cost_usd: Math.round(cost_usd * 100) / 100, - "$insert_id": `first_sync_confirmed:${user.id}:${latestUsage.id}`, - }, - })); + const earliestUsage = earliestUsageResult.data as EarliestUsageRow | null; + if ( + !earliestUsageResult.error + && isWithinFirstSyncConfirmationWindow(earliestUsage?.created_at) + ) { + after(() => captureServerActivationEvent({ + event: "first_sync_confirmed", + distinctId: user.id, + properties: { + surface: "usage_status", + activation_state: "activated", + is_authenticated: true, + session_count, + total_tokens, + total_cost_usd: Math.round(cost_usd * 100) / 100, + "$insert_id": `first_sync_confirmed:${user.id}`, + }, + })); + } return NextResponse.json({ has_data: true, diff --git a/apps/web/components/providers/PublicAnalytics.tsx b/apps/web/components/providers/PublicAnalytics.tsx index 5208087d..f61161c5 100644 --- a/apps/web/components/providers/PublicAnalytics.tsx +++ b/apps/web/components/providers/PublicAnalytics.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useRef } from "react"; import { usePathname, useSearchParams } from "next/navigation"; import { ConsentAwareAnalytics } from "@/components/providers/ConsentAwareAnalytics"; import { useAnalyticsConsent } from "@/components/providers/useAnalyticsConsent"; @@ -22,7 +22,7 @@ export function PublicAnalytics() { function PublicPageviewTracker({ enabled }: { enabled: boolean }) { const pathname = usePathname(); const searchParams = useSearchParams(); - const [lastCapturedUrl, setLastCapturedUrl] = useState(null); + const lastCapturedUrlRef = useRef(null); useEffect(() => { if (!enabled || !pathname) return; @@ -31,11 +31,11 @@ function PublicPageviewTracker({ enabled }: { enabled: boolean }) { const path = query ? `${pathname}?${query}` : pathname; const url = `${window.location.origin}${path}`; - if (url === lastCapturedUrl) return; - setLastCapturedUrl(url); + if (url === lastCapturedUrlRef.current) return; + lastCapturedUrlRef.current = url; void captureConsentedPostHogEvent("$pageview", { $current_url: url }); - }, [enabled, lastCapturedUrl, pathname, searchParams]); + }, [enabled, pathname, searchParams]); return null; } diff --git a/apps/web/lib/analytics/activation.ts b/apps/web/lib/analytics/activation.ts index 21fff3f6..042167d4 100644 --- a/apps/web/lib/analytics/activation.ts +++ b/apps/web/lib/analytics/activation.ts @@ -96,6 +96,16 @@ const ALLOWED_PROPERTY_KEYS = new Set([ "$insert_id", ]); +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; +} + export function isActivationEventName(value: unknown): value is ActivationEventName { return typeof value === "string" && EVENT_SET.has(value as ActivationEventName); } diff --git a/apps/web/lib/analytics/client.ts b/apps/web/lib/analytics/client.ts index 385aab19..0f2d2a3b 100644 --- a/apps/web/lib/analytics/client.ts +++ b/apps/web/lib/analytics/client.ts @@ -76,5 +76,5 @@ export function trackActivationEvent( keepalive: true, }).catch(() => {}); - void captureConsentedPostHogEvent(event, sanitized); + // Activation funnel events are captured exclusively server-side. } diff --git a/apps/web/lib/utils/after.ts b/apps/web/lib/utils/after.ts index b565d07c..854fa48a 100644 --- a/apps/web/lib/utils/after.ts +++ b/apps/web/lib/utils/after.ts @@ -19,6 +19,8 @@ export function after(task: () => unknown | Promise) { }); } catch (error) { if (!isOutsideRequestScopeError(error)) throw error; - Promise.resolve().then(task).catch(() => {}); + Promise.resolve().then(task).catch((taskError) => { + console.error("after() fallback task failed:", taskError); + }); } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 74a25a39..90e37c14 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,8 +2,14 @@ ## Unreleased +### Added + +- **Daily `/api/cron/refresh-open-stats` cron** (Vercel cron, 05:00 UTC) that runs the live open-stats aggregation and persists a durable snapshot. Closes the gap left by the activation performance work, where `/open` and the landing ticker were switched to snapshot-only reads but nothing refreshed the snapshot. + ### Changed +- **Activation funnel events are now captured exclusively server-side.** `trackActivationEvent` no longer double-captures via browser posthog-js for consented users; the consent-exempt, privacy-limited server path (which owns anonymous→user identity stitching) is the single source of truth for funnel math. + - **Unified usage collection on ccusage v20 for both Claude Code and Codex.** The CLI now bundles `ccusage@20.0.8` and invokes its native binary directly (no global install, no PATH lookup), replacing Straude's native Codex collector, token normalizer, pricing aliases, and fingerprinting code (~1,500 lines removed). A single `ccusage daily --json --no-offline` run produces unified Claude+Codex rows with `metadata.agents`; ccusage owns raw-session parsing, dedupe, token accounting, and online pricing. The 20.0.7/20.0.8 releases specifically fix Codex accuracy: archived-session inclusion + dedupe, skipping replayed parent token history in `thread_spawn` subagent sessions, and goal-rollout event dedupe. New `reasoning_output_tokens` column on `daily_usage`/`device_usage` (derived as the residual of authoritative `totalTokens`), collector metadata (`ccusage_version`, `ccusage_agents`, `pricing_mode`) persisted per row, and a one-time 30-day backfill on the first post-migration push (`ccusage_v20_migration_completed_at` config marker). The server rejects unsupported agents and non-online pricing; the new `ccusage-codex-v20` collector joins the trusted set so corrected uploads can lower inflated Codex totals. ### Security @@ -12,6 +18,9 @@ ### Fixed +- **`first_sync_confirmed` no longer fires on every `/api/usage/status` poll for every user with usage.** It now fires only while the user's earliest `daily_usage` row is under 24 hours old (a genuine first sync), with a per-user `$insert_id` (`first_sync_confirmed:{userId}`) so PostHog dedups repeat polls inside the window. +- **`/api/analytics/activation` is rate-limited** (20 req/min per user, or per client IP for anonymous requests) via the durable Supabase-backed limiter, so the unauthenticated funnel endpoint can't be trivially spammed. +- Minor cleanups: shared `getCookieValue` helper in `lib/analytics/activation.ts` (was duplicated in two routes), removed a dead dedup loop in the right-sidebar API route, and the `after()` test-scope fallback now logs task errors instead of swallowing them. - **Codex collector token accounting.** Two bugs collapsed into one user-visible symptom: 1. The dual-candidate token-bucket normalizer could let `cache_read_tokens` exceed `input_tokens` when bucket-level arithmetic drift made the "separate cache" candidate look more consistent than the inclusive one. Replaced with a deterministic inclusive-cache clamp on the codex path. (Defense in depth — see #2 for the actual load-bearing fix.) 2. **The real bug:** `parseSessionFile` interpreted Codex's `total_token_usage` as a session-cumulative counter and computed deltas via subtraction. Verified against real `~/.codex/sessions/` JSONL: that field is the *current request's context snapshot*, not session cumulative — the IDE periodically prunes context, the snapshot drops, and when it grows back, the cumulative-delta logic added the entire regrowth on top of the pre-prune peak. On a real heavy-usage day this inflated the bucket sum by 70x within a single session and 18x at the day level. Fix: prefer the per-event `last_token_usage` field (Codex's actual per-request billing data) and dedupe consecutive events with identical `total_token_usage` snapshots (51% of real events are duplicates; 99.8% of those have matching `last`). Fall back to cumulative-delta only when `last_token_usage` is missing. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c843c017..f1a3a824 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -911,3 +911,15 @@ Pricing the new-logic numbers at gpt-5.5 rates: $228.68 — matches what OpenAI **Bug:** npm 11 silently strips the `bin` entry during publish for ESM packages (`"type": "module"`) that lack a `main` field, with the warning `"bin[straude]" script name was invalid and removed`. The published package had no binary, so `npx straude` failed. **Fix:** Adding `main` pointing to the same entrypoint satisfies npm's validation. The `main` field is redundant for a CLI-only package but harmless. + +--- +**Decision (2026-07-04):** Activation funnel events are captured exclusively server-side; the browser posthog-js duplicate capture in `trackActivationEvent` was removed. + +**Alternatives considered:** (1) Browser capture as canonical — rejected: it only covers consented users, dropping pre-consent top-of-funnel data. (2) Keep both and filter by a `source` property in funnels — rejected: the two paths use different distinct-id graphs (Supabase user id / activation cookie server-side vs. PostHog device id client-side), so identity fragments and anonymous→signed-up conversion math stays wrong even after filtering. + +**Why server-side:** it fires unconditionally (consent-exempt because properties are allowlist-sanitized: no prompts, paths, emails, or raw usage), and it owns `$identify` anonymous→user stitching via the `straude_activation_id` cookie. + +--- +**Decision (2026-07-04):** `first_sync_confirmed` is gated on the user's *earliest* `daily_usage.created_at` being <24h old, with a per-user `$insert_id`, instead of a row-count check or a persisted flag. + +**Alternatives considered:** `count === 1` gate — rejected because a default first sync backfills 3 days (up to 30 with `--days 30`), creating multiple rows at once and missing most genuine first syncs. Persisted `first_sync_confirmed` flag — rejected as a schema migration for something the earliest-row query answers for free. `$insert_id` dedup alone — rejected because PostHog's dedup window is best-effort over days, not a lifetime guarantee; it serves as a backstop inside the 24h window only. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index aa63d42b..3d20f45c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -225,3 +225,9 @@ Dedicated `/notifications` page with infinite scroll pagination, type filtering Generate branded usage summary images (weekly/monthly) for sharing on social media. Includes OG image generation for link previews (1200x630), downloadable square PNG (1080x1080) for Instagram, and a live card preview page at `/recap`. Stats include total spend, output tokens, active days, session count, streak, primary model, and a mini contribution strip. Public users get shareable URLs at `/recap/[username]`; private users can still view and download their own card. Redesign: light theme with 10 FLUX-generated abstract backgrounds (selectable). Contribution strip caps at today (no future-day placeholders). Background choice persists in shareable URLs via `?bg=` param. + +### Activation analytics follow-ups (2026-07-04) + +- Add edge-case tests for the `first_sync_confirmed` gate in `/api/usage/status`: null/invalid `created_at` and earliest-row query error (implementation already fails closed; tests would lock it in). +- Right-sidebar API: the `.not("id", "in", ...)` exclude filter inlines every followed user id into the query — will get unwieldy for users following hundreds of people. Consider an RPC or a join-based exclusion. +- PostHog project setup (from PR #139): activation funnel actions/dashboards still need to be created in PostHog itself (no MCP/CLI key available in the checkout at the time). diff --git a/vercel.json b/vercel.json index 399c5a65..a41045c1 100644 --- a/vercel.json +++ b/vercel.json @@ -3,6 +3,10 @@ { "path": "/api/cron/nudge-inactive", "schedule": "0 * * * *" + }, + { + "path": "/api/cron/refresh-open-stats", + "schedule": "0 5 * * *" } ] }