diff --git a/crm/README.md b/crm/README.md index 3dfab701c..c9bfa0f51 100644 --- a/crm/README.md +++ b/crm/README.md @@ -11,11 +11,13 @@ One shared password, per-login sessions, one Railway service, no database. | Overview | PostHog, blob, Prometheus | visitors, pageviews, sessions (7 d vs previous 7 d), **AI-referred visitors** (ChatGPT, Perplexity, Claude, Gemini, Copilot, …), search-referred visitors, 28 d daily series, 12 w weekly series with the AI share, channels, AI domains, sections | | Pages | PostHog | sections week over week, biggest gains and losses, top 100 pages (filter by section), entry pages | | Audience | PostHog | new vs returning, bounce, countries, devices, UTM sources, referring domains with their channel | +| Actions | PostHog custom events | outbound clicks by destination host (visitors sent to providers), copies (endpoint, API URL, MCP, embed) per bench, search queries with the result picked | | Data health | index blob, Prometheus, Dune | live / stale (> 24 h) / expired (> 7 d) benches per category, benches needing attention, scrape targets down, Dune credits and period end, the daily history kept on the volume | -The site captures `$pageview` and `$pageleave` only (autocapture off, nobody -identified); every query reads `$pageview`, so every traffic number is a -pageview aggregate and a visitor is a device cookie. Events from staging and +The site captures `$pageview`, `$pageleave` and three custom events +(`outbound_click`, `copy`, `search`, see `src/lib/analytics.ts`; autocapture +off, nobody identified). Traffic pages read `$pageview`, the Actions page the +custom events; a visitor is a device cookie. Events from staging and localhost are excluded (`properties.$host`). Bench health reads `aggregate/index.json` (every bench with its status and @@ -27,10 +29,10 @@ from the sitemap before publishing, which is exactly what this page must show. PostHog allows **2400 query requests per hour per organisation**, shared by every key and every team member. This app never queries in the request path: -- a refresh runs a **fixed list of 11 HogQL queries**, one at a time +- a refresh runs a **fixed list of 15 HogQL queries**, one at a time (`lib/traffic.ts`), and writes a snapshot; pages read the snapshot; - the scheduler (`instrumentation.ts`) refreshes every `REFRESH_MINUTES` - (default 60): **11 queries per hour, about 0.5 % of the organisation's + (default 60): **15 queries per hour, about 0.6 % of the organisation's budget**; - the Refresh button is refused for 10 minutes after any refresh; - a local budget (`POSTHOG_HOURLY_BUDGET`, default 300 per rolling hour) is a diff --git a/crm/app/actions/page.tsx b/crm/app/actions/page.tsx new file mode 100644 index 000000000..8384e0810 --- /dev/null +++ b/crm/app/actions/page.tsx @@ -0,0 +1,148 @@ +import { Shell } from "@/components/shell"; +import { Delta, Empty, fmtInt, Kpi } from "@/components/ui"; +import { readSnapshot } from "@/lib/snapshot"; + +export const dynamic = "force-dynamic"; +const SITE = `https://${process.env.SITE_HOST ?? "openchainbench.com"}`; + +const ACTION_LABEL: Record = { + outbound_click: "Outbound clicks", + copy: "Copies", + search: "Searches", +}; + +export default async function ActionsPage({ searchParams }: { searchParams: Promise<{ refresh?: string }> }) { + const [snap, sp] = await Promise.all([readSnapshot(), searchParams]); + const t = snap.traffic; + const actions = t.actions ?? []; + const byName = (n: string) => actions.find((a) => a.name === n); + const outbound = (t.outbound ?? []).filter((o) => o.clicks > 0 || o.prevClicks > 0); + const copies = t.copies ?? []; + const searches = t.searches ?? []; + + return ( + +
+ {(["outbound_click", "copy", "search"] as const).map((n) => { + const a = byName(n); + return ( + + ); + })} +
+

+ Events the site sends on top of pageviews (src/lib/analytics.ts). Nothing here before the site deploy that added them; the + Actions numbers are what the traffic turns into. +

+ +
+
+

Where visitors go, 7 d (outbound clicks by host)

+ {outbound.length > 0 ? ( + + + + + + + + + + + + {outbound.map((o) => ( + + + + + + + + ))} + +
HostClicksw/wVisitorsFrom
{o.host}{fmtInt(o.clicks)} + + {fmtInt(o.visitors)} + {o.topPage} +
+ ) : ( + + )} +
+
+

What gets copied, 7 d

+ {copies.length > 0 ? ( + + + + + + + + + + + {copies.map((c, i) => ( + + + + + + + ))} + +
KindValueBenchCopies
{c.kind} + {c.value || "–"} + + {c.bench ? ( + + {c.bench} + + ) : ( + "–" + )} + {fmtInt(c.count)}
+ ) : ( + + )} +
+
+ +
+

What people search for, 7 d (a result was picked)

+ {searches.length > 0 ? ( + + + + + + + + + + + {searches.map((s) => ( + + + + + + + ))} + +
QueryTimesKindLanded on
{s.query}{fmtInt(s.count)}{s.kind || "–"} + {s.url} +
+ ) : ( + + )} +
+
+ ); +} diff --git a/crm/components/shell.tsx b/crm/components/shell.tsx index 273a49c8c..f71ce5420 100644 --- a/crm/components/shell.tsx +++ b/crm/components/shell.tsx @@ -5,6 +5,7 @@ const NAV = [ ["/", "Overview"], ["/pages", "Pages"], ["/audience", "Audience"], + ["/actions", "Actions"], ["/health", "Data health"], ] as const; diff --git a/crm/lib/traffic.ts b/crm/lib/traffic.ts index 690491df6..52048f5e8 100644 --- a/crm/lib/traffic.ts +++ b/crm/lib/traffic.ts @@ -1,8 +1,9 @@ /** * The PostHog side of the snapshot: one fixed list of HogQL queries per - * refresh (eleven today), each mapped to a plain JSON section. Every query is + * refresh (fifteen today), each mapped to a plain JSON section. Every query is * scoped to the production host, so staging and localhost never count, and - * to `$pageview`, the only event the site captures today (autocapture is off). + * to one named event: `$pageview` for the traffic sections, the three custom + * events of src/lib/analytics.ts for the Actions sections (autocapture is off). * * Distinct id, not person id: the site runs `person_profiles: identified_only` * and never identifies anyone, so a visitor is a device cookie. @@ -13,6 +14,8 @@ import { num, queryHogQL, str } from "@/lib/posthog"; const SITE_HOST = process.env.SITE_HOST ?? "openchainbench.com"; const HOST_FILTER = `properties.$host = '${SITE_HOST}'`; const PV = `event = '$pageview' AND ${HOST_FILTER}`; +// The site's custom events (src/lib/analytics.ts): outbound_click, copy, search. +const CUSTOM = `event IN ('outbound_click', 'copy', 'search') AND ${HOST_FILTER}`; export type DailyPoint = { day: string; pageviews: number; visitors: number; sessions: number }; export type WeeklyPoint = { week: string; visitors: number; ai: number; search: number; pageviews: number }; @@ -20,6 +23,10 @@ export type PageRow = { path: string; section: Section; visitors: number; prevVi export type ReferrerRow = { domain: string; channel: Channel; visitors: number; prevVisitors: number; pageviews: number }; export type NamedCount = { name: string; visitors: number; share: number }; export type EntryRow = { path: string; section: Section; sessions: number }; +export type ActionRow = { name: string; count: number; prevCount: number; visitors: number }; +export type OutboundRow = { host: string; clicks: number; prevClicks: number; visitors: number; topPage: string }; +export type SearchRow = { query: string; count: number; kind: string; url: string }; +export type CopyRow = { kind: string; value: string; bench: string; count: number }; export type Traffic = { daily: DailyPoint[]; @@ -45,6 +52,10 @@ export type Traffic = { prevSearchVisitors: number; }; engagement: { pagesPerSession: number; bounceRate: number; sessions: number }; + actions: ActionRow[]; + outbound: OutboundRow[]; + searches: SearchRow[]; + copies: CopyRow[]; }; export const QUERIES = { @@ -119,6 +130,24 @@ export const QUERIES = { SELECT properties.$session_id AS s, count() AS n FROM events WHERE ${PV} AND timestamp >= now() - INTERVAL 7 DAY AND s IS NOT NULL GROUP BY s )`, + actions: () => ` + SELECT event, countIf(timestamp >= now() - INTERVAL 7 DAY) AS n, countIf(timestamp < now() - INTERVAL 7 DAY) AS prev_n, + uniqIf(distinct_id, timestamp >= now() - INTERVAL 7 DAY) AS visitors + FROM events WHERE ${CUSTOM} AND timestamp >= now() - INTERVAL 14 DAY + GROUP BY event ORDER BY n DESC`, + outbound: () => ` + SELECT properties.host AS host, countIf(timestamp >= now() - INTERVAL 7 DAY) AS clicks, countIf(timestamp < now() - INTERVAL 7 DAY) AS prev_clicks, + uniqIf(distinct_id, timestamp >= now() - INTERVAL 7 DAY) AS visitors, topK(1)(properties.page) AS top_page + FROM events WHERE event = 'outbound_click' AND ${HOST_FILTER} AND timestamp >= now() - INTERVAL 14 DAY + GROUP BY host ORDER BY greatest(clicks, prev_clicks) DESC LIMIT 40`, + searches: () => ` + SELECT lower(properties.query) AS q, count() AS n, topK(1)(properties.kind) AS kind, topK(1)(properties.url) AS url + FROM events WHERE event = 'search' AND ${HOST_FILTER} AND timestamp >= now() - INTERVAL 7 DAY AND q != '' + GROUP BY q ORDER BY n DESC LIMIT 40`, + copies: () => ` + SELECT properties.kind AS kind, properties.value AS value, properties.bench AS bench, count() AS n + FROM events WHERE event = 'copy' AND ${HOST_FILTER} AND timestamp >= now() - INTERVAL 7 DAY + GROUP BY kind, value, bench ORDER BY n DESC LIMIT 40`, } as const; export type TrafficSection = keyof typeof QUERIES; @@ -165,6 +194,16 @@ export async function loadTrafficSection(section: TrafficSection): Promise ({ name: str(r[0]), count: num(r[1]), prevCount: num(r[2]), visitors: num(r[3]) })) }; + case "outbound": + return { + outbound: rows.map((r) => ({ host: str(r[0]) || "?", clicks: num(r[1]), prevClicks: num(r[2]), visitors: num(r[3]), topPage: str(Array.isArray(r[4]) ? r[4][0] : r[4]) })), + }; + case "searches": + return { searches: rows.map((r) => ({ query: str(r[0]), count: num(r[1]), kind: str(Array.isArray(r[2]) ? r[2][0] : r[2]), url: str(Array.isArray(r[3]) ? r[3][0] : r[3]) })) }; + case "copies": + return { copies: rows.map((r) => ({ kind: str(r[0]) || "other", value: str(r[1]), bench: str(r[2]), count: num(r[3]) })) }; case "engagement": return { engagement: { pagesPerSession: num(rows[0]?.[0]), bounceRate: num(rows[0]?.[1]), sessions: num(rows[0]?.[2]) } }; } diff --git a/crm/test/traffic.test.ts b/crm/test/traffic.test.ts index 513373256..e8cd0c735 100644 --- a/crm/test/traffic.test.ts +++ b/crm/test/traffic.test.ts @@ -2,15 +2,15 @@ import { describe, expect, test } from "bun:test"; import { channelTotals, QUERIES, sectionTotals, sumWindow, TRAFFIC_SECTIONS } from "../lib/traffic"; describe("queries", () => { - test("every query is scoped to pageviews on the production host", () => { + test("every query is scoped to the production host and to a named event", () => { for (const name of TRAFFIC_SECTIONS) { const q = QUERIES[name](); - expect(q).toContain("event = '$pageview'"); expect(q).toContain("properties.$host = 'openchainbench.com'"); + expect(/event (= '\$pageview'|= '(outbound_click|search|copy)'|IN \('outbound_click', 'copy', 'search'\))/.test(q)).toBe(true); } }); test("the refresh spends a bounded number of queries", () => { - expect(TRAFFIC_SECTIONS.length).toBeLessThanOrEqual(12); + expect(TRAFFIC_SECTIONS.length).toBeLessThanOrEqual(16); }); test("the weekly series and the totals embed the AI domain list", () => { expect(QUERIES.weekly()).toContain("'chatgpt.com'"); diff --git a/src/app/mcp/page.tsx b/src/app/mcp/page.tsx index bc8d9622b..11d3e427b 100644 --- a/src/app/mcp/page.tsx +++ b/src/app/mcp/page.tsx @@ -151,7 +151,7 @@ export default async function McpPage() { {MCP_URL} - + @@ -177,7 +177,7 @@ export default async function McpPage() { {" "} (macOS) or the equivalent on your OS, then restart the app.

- +

Once connected, the three tools appear under the 🔌 icon in the chat input. Ask Claude{" "} @@ -205,7 +205,7 @@ export default async function McpPage() { :

- + {/* Other clients */} @@ -226,7 +226,7 @@ export default async function McpPage() { : all accept the same URL with the streamable-HTTP transport. SSE is intentionally disabled. Anything else, raw curl works:

- + {/* What's exposed */} @@ -337,14 +337,14 @@ export default async function McpPage() { ); } -function CodeBlock({ value }: { value: string }) { +function CodeBlock({ value, name }: { value: string; name: string }) { return (
         {value}
       
- +
); diff --git a/src/components/ai-brief-block.tsx b/src/components/ai-brief-block.tsx index e9b542c13..15c3bc845 100644 --- a/src/components/ai-brief-block.tsx +++ b/src/components/ai-brief-block.tsx @@ -13,7 +13,7 @@ export function AiBriefBlock() { tag="any LLM" title="Web brief" desc="Plain markdown. Paste into ChatGPT, Claude, Cursor, Aider, Codex, Continue or any chat-based agent." - action={} + action={} link={{ label: "View raw", href: "/contribute/ai-brief.md" }} /> } + action={} link={{ label: "ClawHub listing ↗", href: "https://clawhub.ai/skills/openchainbench-contributor", external: true }} />

diff --git a/src/components/badges-catalog.tsx b/src/components/badges-catalog.tsx index f9ab9cf27..2542aa364 100644 --- a/src/components/badges-catalog.tsx +++ b/src/components/badges-catalog.tsx @@ -449,6 +449,7 @@ function EmbedModal({ diff --git a/src/components/citation-bar.tsx b/src/components/citation-bar.tsx index 5b550f4a9..c0329f3c2 100644 --- a/src/components/citation-bar.tsx +++ b/src/components/citation-bar.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Check, Copy } from "lucide-react"; +import { track } from "@/lib/analytics"; const ORIGIN = "https://openchainbench.com"; @@ -21,6 +22,7 @@ export function CitationBar({ slug }: { slug: string }) { async function onCopy() { try { await navigator.clipboard.writeText(apiUrl); + track("copy", { kind: "api_url", bench: slug }); setCopied(true); window.setTimeout(() => setCopied(false), 1400); } catch { diff --git a/src/components/copy-button.tsx b/src/components/copy-button.tsx index 63ea41f96..892043082 100644 --- a/src/components/copy-button.tsx +++ b/src/components/copy-button.tsx @@ -2,20 +2,26 @@ import { useState } from "react"; import { Check, Copy } from "lucide-react"; +import { track, type SiteEvent } from "@/lib/analytics"; /** * Inline "Copy to clipboard" button. Toggles to "Copied" for 1.5 s after * a successful write. Shared by the contribute / mcp / docs pages so every * copy-able snippet on the site looks identical. */ +type CopyProps = Extract["props"]; + export function CopyButton({ value, label, mono, + event, }: { value: string; label: string; mono?: boolean; + /** What was copied, for the `copy` event; omitted means "other" with no value. */ + event?: CopyProps; }) { const [copied, setCopied] = useState(false); @@ -24,6 +30,7 @@ export function CopyButton({ type="button" onClick={() => { navigator.clipboard.writeText(value).then(() => { + track("copy", event ?? { kind: "other" }); setCopied(true); window.setTimeout(() => setCopied(false), 1500); }); diff --git a/src/components/embed-badge-button.tsx b/src/components/embed-badge-button.tsx index 74d5b4735..619cfb46d 100644 --- a/src/components/embed-badge-button.tsx +++ b/src/components/embed-badge-button.tsx @@ -280,6 +280,7 @@ export function EmbedBadgeButton({ diff --git a/src/components/posthog-provider.tsx b/src/components/posthog-provider.tsx index f7a913eee..9feb5bacd 100644 --- a/src/components/posthog-provider.tsx +++ b/src/components/posthog-provider.tsx @@ -4,6 +4,39 @@ import posthog from "posthog-js"; import { PostHogProvider } from "posthog-js/react"; import { usePathname, useSearchParams } from "next/navigation"; import { useEffect, Suspense } from "react"; +import { track } from "@/lib/analytics"; + +/** + * Outbound clicks, delegated: one listener for every link that leaves the + * site (provider websites on /products, endpoint hosts, X, GitHub). The + * event carries the destination host and the page it was clicked from, + * which is the number a benchmark site lives on: visitors sent to providers. + */ +function OutboundClicks() { + useEffect(() => { + const onClick = (e: MouseEvent) => { + const a = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null; + if (!a) return; + let url: URL; + try { + url = new URL(a.href, window.location.href); + } catch { + return; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return; + if (url.host === window.location.host) return; + track("outbound_click", { + href: url.href, + host: url.host, + text: (a.textContent ?? "").trim().slice(0, 80), + page: window.location.pathname, + }); + }; + document.addEventListener("click", onClick, { capture: true }); + return () => document.removeEventListener("click", onClick, { capture: true }); + }, []); + return null; +} function PostHogPageview() { const pathname = usePathname(); @@ -37,6 +70,7 @@ export function PHProvider({ children }: { children: React.ReactNode }) { + {children} ); diff --git a/src/components/public-endpoints-section.tsx b/src/components/public-endpoints-section.tsx index 5a1e800a3..de1f9c963 100644 --- a/src/components/public-endpoints-section.tsx +++ b/src/components/public-endpoints-section.tsx @@ -110,7 +110,7 @@ export async function PublicEndpointsSection({ benchmark }: { benchmark: Benchma {r.endpoint} - + {fmtUnit(r.ms.p50, benchmark.unit)} diff --git a/src/components/search/search-dialog.tsx b/src/components/search/search-dialog.tsx index a1c078dab..da1e457b1 100644 --- a/src/components/search/search-dialog.tsx +++ b/src/components/search/search-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { Command } from "cmdk"; +import { track } from "@/lib/analytics"; import Fuse from "fuse.js"; import { ArrowRight, @@ -237,6 +238,9 @@ export default function SearchDialog() { function go(url: string, entry?: RecentEntry) { if (entry) pushRecent(entry); + // What people search for and where they land: the query column tells + // which benches and providers are asked for and missing. + track("search", { query: query.trim().slice(0, 80), kind: entry?.kind ?? "", url }); close(); router.push(url); } diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 000000000..eb36ae785 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,29 @@ +/** + * The site's custom PostHog events, one place. `track` is a no-op when + * PostHog is not initialised (no key, server side, blocked script), so call + * sites never guard. + * + * outbound_click {href, host, text, page} a link to another site, delegated listener + * copy {kind, value?, bench?} endpoint / API URL / MCP URL or config / embed / brief + * search {query, kind, url} a result picked in the search dialog + * + * Properties carry no personal data: URLs of our own pages and of public + * endpoints, the query the visitor typed, the provider host they left for. + */ +import posthog from "posthog-js"; + +export type SiteEvent = + | { name: "outbound_click"; props: { href: string; host: string; text: string; page: string } } + | { name: "copy"; props: { kind: "endpoint" | "api_url" | "mcp_url" | "mcp_config" | "embed" | "brief" | "other"; value?: string; bench?: string } } + | { name: "search"; props: { query: string; kind: string; url: string } }; + +/** Typed per event: `track("search", { kind: "endpoint" })` does not compile. */ +export function track(name: N, props: Extract["props"]): void { + if (typeof window === "undefined") return; + try { + if (!posthog.__loaded) return; + posthog.capture(name, props); + } catch { + // analytics never breaks the page + } +}