diff --git a/apps/api/src/routes/dev-plans.ts b/apps/api/src/routes/dev-plans.ts
index 9bd65d0f72..5dddc3787f 100644
--- a/apps/api/src/routes/dev-plans.ts
+++ b/apps/api/src/routes/dev-plans.ts
@@ -7,6 +7,7 @@ import {
executeSelfRefund,
isSelfRefundCandidateType,
} from "@/lib/self-refund.js";
+import { posthog } from "@/posthog.js";
import {
ensureStripeCustomer,
finalizeDevPlanSetupSession,
@@ -3216,6 +3217,17 @@ devPlans.openapi(redeemResetPass, async (c) => {
},
});
+ posthog.capture({
+ distinctId: user.id,
+ event: "reset_pass_redeemed",
+ groups: { organization: personalOrg.id },
+ properties: {
+ devPlan: tier,
+ source,
+ organization: personalOrg.id,
+ },
+ });
+
return c.json({
success: true,
source,
diff --git a/apps/code/src/app/dashboard/components/UsageOverview.tsx b/apps/code/src/app/dashboard/components/UsageOverview.tsx
index 5110bbd84c..c68029f84d 100644
--- a/apps/code/src/app/dashboard/components/UsageOverview.tsx
+++ b/apps/code/src/app/dashboard/components/UsageOverview.tsx
@@ -2,7 +2,10 @@
import { format, formatDistanceToNowStrict } from "date-fns";
import { Activity, Coins, Cpu, Gem, TrendingUp } from "lucide-react";
+import { usePostHog } from "posthog-js/react";
+import { useEffect } from "react";
+import { useAppConfig } from "@/lib/config";
import { useApi } from "@/lib/fetch-client";
import { AgentModelUsageChart } from "./AgentModelUsageChart";
@@ -219,6 +222,8 @@ export default function UsageOverview({
cycle = "monthly",
}: UsageOverviewProps) {
const api = useApi();
+ const posthog = usePostHog();
+ const { posthogKey } = useAppConfig();
const tierKey = planName.toLowerCase();
// The monthly credit pool (not the weekly premium allowance) is the hard
@@ -226,6 +231,28 @@ export default function UsageOverview({
// card gives way to the upgrade/PAYG promo.
const monthlyExhausted = creditsLimit > 0 && creditsUsed >= creditsLimit;
+ // Top of the Reset Pass upsell funnel: the user sees the exhausted weekly
+ // premium meter. reset_pass_purchased/redeemed are captured server-side,
+ // so without this event the funnel has a bottom but no top.
+ const weeklyExhausted =
+ premiumWeeklyLimit > 0 && premiumCreditsUsed >= premiumWeeklyLimit;
+ useEffect(() => {
+ if (!posthogKey || !weeklyExhausted) {
+ return;
+ }
+ posthog.capture("devpass_weekly_cap_hit_viewed", {
+ tier: tierKey,
+ weeklyLimit: premiumWeeklyLimit,
+ weeklyUsed: premiumCreditsUsed,
+ resetsAt: premiumWeekResetsAt,
+ purchasedPasses: resetPasses,
+ includedPassesRemaining: includedResetPassesRemaining,
+ monthlyExhausted,
+ });
+ // Fire once per exhausted dashboard view, not on every prop tick.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [posthogKey, weeklyExhausted]);
+
const { data: activity } = api.useQuery(
"get",
"/activity",
diff --git a/apps/playground/next.config.ts b/apps/playground/next.config.ts
index 6805f7c64f..37a3ebcf1d 100644
--- a/apps/playground/next.config.ts
+++ b/apps/playground/next.config.ts
@@ -26,6 +26,21 @@ const nextConfig: NextConfig = {
"@json-render/react-pdf",
"@json-render/image",
],
+ async rewrites() {
+ return [
+ // First-party PostHog ingestion proxy — ad blockers block
+ // *.posthog.com directly, silently dropping client events. The
+ // client is configured with api_host: "/ingest" (providers.tsx).
+ {
+ source: "/ingest/static/:path*",
+ destination: "https://us-assets.i.posthog.com/static/:path*",
+ },
+ {
+ source: "/ingest/:path*",
+ destination: "https://us.i.posthog.com/:path*",
+ },
+ ];
+ },
typescript: {
ignoreBuildErrors: true,
},
diff --git a/apps/playground/src/components/providers.tsx b/apps/playground/src/components/providers.tsx
index e7e17231e6..f769bd8634 100644
--- a/apps/playground/src/components/providers.tsx
+++ b/apps/playground/src/components/providers.tsx
@@ -41,16 +41,23 @@ export function Providers({ children, config }: ProvidersProps) {
const host = config.posthogHost;
const init = () => {
posthog.init(key, {
- api_host: host,
+ // Ingest through our own origin (see the /ingest rewrites in
+ // next.config.ts) so ad blockers that block *.posthog.com don't
+ // silently drop client events.
+ api_host: "/ingest",
+ ui_host: host,
capture_pageview: "history_change",
autocapture: true,
});
};
+ // Captures fired before init() are dropped by posthog-js, so the idle
+ // deferral must be bounded — a busy main thread (e.g. the chat page)
+ // can starve requestIdleCallback long enough for a user to act.
if (typeof requestIdleCallback !== "undefined") {
- const id = requestIdleCallback(init);
+ const id = requestIdleCallback(init, { timeout: 800 });
return () => cancelIdleCallback(id);
}
- const timer = setTimeout(init, 1000);
+ const timer = setTimeout(init, 300);
return () => clearTimeout(timer);
}, [config.posthogKey, config.posthogHost]);
diff --git a/apps/ui/next.config.ts b/apps/ui/next.config.ts
index b2f4fd7a9a..301d4a73f4 100644
--- a/apps/ui/next.config.ts
+++ b/apps/ui/next.config.ts
@@ -422,6 +422,17 @@ const nextConfig: NextConfig = {
source: "/docs-health",
destination: "https://docs.llmgateway.io/health",
},
+ // First-party PostHog ingestion proxy — ad blockers block
+ // *.posthog.com directly, silently dropping client events. The
+ // client is configured with api_host: "/ingest" (providers.tsx).
+ {
+ source: "/ingest/static/:path*",
+ destination: "https://us-assets.i.posthog.com/static/:path*",
+ },
+ {
+ source: "/ingest/:path*",
+ destination: "https://us.i.posthog.com/:path*",
+ },
];
},
typescript: {
diff --git a/apps/ui/src/components/blog/blog-cta.tsx b/apps/ui/src/components/blog/blog-cta.tsx
new file mode 100644
index 0000000000..973edc95a1
--- /dev/null
+++ b/apps/ui/src/components/blog/blog-cta.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { usePostHog } from "posthog-js/react";
+
+import { Button } from "@/lib/components/button";
+
+// Conversion card embeddable in markdown content via the `BlogCta` override in
+// getMarkdownOptions. The DevPass variant leans into the passport/boarding-pass
+// brand language used across DevPass surfaces.
+export function BlogCta({
+ variant = "devpass",
+ location = "inline",
+}: {
+ variant?: "devpass" | "gateway";
+ location?: string;
+}) {
+ const posthog = usePostHog();
+ const pathname = usePathname();
+ const post = pathname?.split("/").pop() ?? "unknown";
+
+ const track = (cta: string) => {
+ posthog.capture("cta_clicked", {
+ location: `blog_${location}`,
+ cta,
+ post,
+ });
+ };
+
+ if (variant === "gateway") {
+ return (
+
+
+ LLM Gateway
+
+
+ One API key for every model.
+
+
+ Route to 200+ models with automatic failover, caching, and real-time
+ cost analytics. Free to start — no credit card required.
+
+
+
+ track("create_free_account")}
+ >
+ Create free account
+
+
+ track("browse_models")}
+ className="text-sm text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground"
+ >
+ Browse all models
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ DevPass · Boarding pass
+
+
+ No. DP-{post.slice(0, 6).toUpperCase()}
+
+
+
+ Every frontier model. One flat rate.
+
+
+ Run Claude, GPT-5.5, Gemini, and 200+ more through Claude Code,
+ Cursor, or whatever tool you already use. From $29/mo — no token math,
+ no surprise invoices.
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/landing/navbar.tsx b/apps/ui/src/components/landing/navbar.tsx
index 03c45d25df..868a68bb5c 100644
--- a/apps/ui/src/components/landing/navbar.tsx
+++ b/apps/ui/src/components/landing/navbar.tsx
@@ -30,6 +30,7 @@ import {
Zap,
} from "lucide-react";
import Link from "next/link";
+import { usePostHog } from "posthog-js/react";
import { useEffect, useState } from "react";
import { AuthLink } from "@/components/shared/auth-link";
@@ -70,8 +71,12 @@ function IconMenuItem({
gradient: string;
external?: boolean;
}) {
+ const posthog = usePostHog();
const linkClassName = cn(
- "group/product flex items-start gap-3 select-none rounded-lg p-3 no-underline outline-none transition-all duration-300 bg-linear-to-br from-transparent to-transparent",
+ // flex-row is load-bearing: NavigationMenuLink's base classes include
+ // flex-col and are concatenated onto this link via Radix Slot, so the
+ // direction must be asserted explicitly or the card stacks vertically.
+ "group/product flex flex-row items-start gap-3 select-none rounded-lg p-3 no-underline outline-none transition-all duration-300 bg-linear-to-br from-transparent to-transparent",
gradient,
"hover:shadow-lg focus:shadow-md",
);
@@ -96,6 +101,10 @@ function IconMenuItem({
>
);
+ const handleClick = () => {
+ posthog.capture("nav_link_clicked", { link: title, area: "dropdown" });
+ };
+
return (
@@ -105,11 +114,17 @@ function IconMenuItem({
target="_blank"
rel="noopener noreferrer"
className={linkClassName}
+ onClick={handleClick}
>
{inner}
) : (
-
+
{inner}
)}
@@ -130,9 +145,14 @@ export const Navbar = ({
providers?: ApiProvider[];
}) => {
const config = useAppConfig();
+ const posthog = usePostHog();
const { isAuthenticated: hasSession, isLoading } = useSessionStatus();
const isAuthenticated = hasSession && !isLoading;
+ const trackNav = (link: string) => {
+ posthog.capture("nav_link_clicked", { link, area: "navbar" });
+ };
+
const productsLinks: Array<{
title: string;
href: string;
@@ -421,7 +441,7 @@ export const Navbar = ({
prefetch={true}
>
-
+
LLM Gateway
@@ -437,18 +457,58 @@ export const Navbar = ({
{/* Desktop center nav */}
-
-
+
+
+ trackNav("DevPass")}
+ className="text-muted-foreground hover:text-accent-foreground block py-2.5 duration-150"
+ >
+ DevPass
+
+
+
+ trackNav("Chat")}
+ className="text-muted-foreground hover:text-accent-foreground block py-2.5 duration-150"
+ >
+ Chat
+
+
- {/* GitHub stars (compact) + Discord */}
-
+ {/* GitHub stars (compact) + Discord — hidden in the narrow
+ band above the nav breakpoint so the promoted nav links
+ don't collide with the right-side controls. */}
+
{children}
{
posthog.init(key, {
- api_host: host,
+ // Ingest through our own origin (see the /ingest rewrites in
+ // next.config.ts) so ad blockers that block *.posthog.com don't
+ // silently drop client events.
+ api_host: "/ingest",
+ ui_host: host,
capture_pageview: "history_change",
autocapture: true,
});
};
+ // Captures fired before init() are dropped by posthog-js, so the idle
+ // deferral must be bounded — a busy main thread can starve
+ // requestIdleCallback long enough for a user to act.
if (typeof requestIdleCallback !== "undefined") {
- const id = requestIdleCallback(init);
+ const id = requestIdleCallback(init, { timeout: 800 });
return () => cancelIdleCallback(id);
}
- const timer = setTimeout(init, 1000);
+ const timer = setTimeout(init, 300);
return () => clearTimeout(timer);
}, [config.posthogKey, config.posthogHost]);
diff --git a/apps/ui/src/content/blog/2026-06-22-best-ai-coding-plans.md b/apps/ui/src/content/blog/2026-06-22-best-ai-coding-plans.md
index 9f139b5b55..57b77bae8a 100644
--- a/apps/ui/src/content/blog/2026-06-22-best-ai-coding-plans.md
+++ b/apps/ui/src/content/blog/2026-06-22-best-ai-coding-plans.md
@@ -50,6 +50,8 @@ export ANTHROPIC_API_KEY="$LLM_GATEWAY_API_KEY"
# every one of the 200+ models is now a single switch away
```
+
+
---
## 2. Claude Code (Anthropic Max)
@@ -325,4 +327,6 @@ Switch to one flat rate for every model in under two minutes:
No per-token math. No vendor lock-in. Just every model under one key.
-**[Get DevPass](https://devpass.llmgateway.io/signup?plan=pro)** | **[Compare DevPass vs Cursor](https://devpass.llmgateway.io/compare/cursor)** | **[Read the 7 best AI gateways](/blog/best-ai-gateways)**
+
+
+**[Compare DevPass vs Cursor](https://devpass.llmgateway.io/compare/cursor)** | **[Read the 7 best AI gateways](/blog/best-ai-gateways)**
diff --git a/apps/ui/src/lib/utils/markdown.tsx b/apps/ui/src/lib/utils/markdown.tsx
index 8d75782db0..c04ac9a1c3 100644
--- a/apps/ui/src/lib/utils/markdown.tsx
+++ b/apps/ui/src/lib/utils/markdown.tsx
@@ -1,3 +1,5 @@
+import { BlogCta } from "@/components/blog/blog-cta";
+
import { SyntaxHighlightedPre } from "./markdown-code-block";
export interface ChangelogFrontmatter {
@@ -22,6 +24,9 @@ export interface ChangelogEntry extends ChangelogFrontmatter {
export function getMarkdownOptions() {
return {
overrides: {
+ BlogCta: {
+ component: BlogCta,
+ },
h1: {
props: {
className:
diff --git a/packages/scripts/src/traffic-report.ts b/packages/scripts/src/traffic-report.ts
index f4630ec870..9512b015eb 100644
--- a/packages/scripts/src/traffic-report.ts
+++ b/packages/scripts/src/traffic-report.ts
@@ -65,7 +65,14 @@ const PRODUCTS: ReadonlyArray<{ host: string; label: string }> = [
{ host: "docs.llmgateway.io", label: "Docs" },
];
-const EVENTS: ReadonlyArray<{ event: string; label: string }> = [
+// `unique: true` counts distinct persons instead of raw events. Click events
+// use it because a single bot hammering a page can otherwise dominate the
+// weekly number (e.g. 708 pricing clicks from one crawler on 2026-07-11).
+const EVENTS: ReadonlyArray<{
+ event: string;
+ label: string;
+ unique?: boolean;
+}> = [
{ event: "user_signed_up", label: "Signups" },
{ event: "credits_purchased", label: "Credit purchases" },
{ event: "dev_plan_started", label: "DevPass starts" },
@@ -73,8 +80,8 @@ const EVENTS: ReadonlyArray<{ event: string; label: string }> = [
{ event: "reset_pass_purchased", label: "Reset passes" },
{ event: "onboarding_completed", label: "Onboarding done" },
{ event: "playground_chat_sent", label: "Playground chats" },
- { event: "cta_clicked", label: "CTA clicks" },
- { event: "pricing_plan_clicked", label: "Pricing clicks" },
+ { event: "cta_clicked", label: "CTA clickers", unique: true },
+ { event: "pricing_plan_clicked", label: "Pricing clickers", unique: true },
{ event: "enterprise_contact_submitted", label: "Enterprise leads" },
];
@@ -268,7 +275,8 @@ async function fetchReport(window: ReportWindow): Promise {
const eventList = EVENTS.map((e) => `'${e.event}'`).join(",");
const eventsQuery = `
- SELECT event, ${periodExpr} AS period, count() AS hits
+ SELECT event, ${periodExpr} AS period, count() AS hits,
+ count(DISTINCT person_id) AS unique_hits
FROM events
WHERE event IN (${eventList}) AND ${rangeExpr}
GROUP BY event, period`;
@@ -433,18 +441,23 @@ async function fetchReport(window: ReportWindow): Promise {
}
const eventsData = new Map();
+ const uniqueEvents = new Set(
+ EVENTS.filter((e) => e.unique).map((e) => e.event),
+ );
for (const { event } of EVENTS) {
eventsData.set(event, { current: 0, previous: 0 });
}
for (const row of events) {
- const entry = eventsData.get(String(row[0]));
+ const event = String(row[0]);
+ const entry = eventsData.get(event);
if (!entry) {
continue;
}
+ const value = uniqueEvents.has(event) ? num(row[3]) : num(row[2]);
if (row[1] === "current") {
- entry.current = num(row[2]);
+ entry.current = value;
} else {
- entry.previous = num(row[2]);
+ entry.previous = value;
}
}