From 489f95d5fc0d8e63e8088da4e7723f31042563b0 Mon Sep 17 00:00:00 2001
From: Flotapponnier
Date: Sun, 20 Sep 2026 13:13:21 +0200
Subject: [PATCH 1/2] posthog: three custom events (outbound_click by delegated
listener, copy with kind/bench, search with the query and the result picked)
so the CRM measures what the traffic turns into; crm: Actions page (outbound
hosts, copies per bench, search queries), four more HogQL queries per hourly
refresh (15 total)
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01HJgbZCqjR4nvCfcJSzofbw
---
crm/README.md | 12 +-
crm/app/actions/page.tsx | 148 ++++++++++++++++++++
crm/components/shell.tsx | 1 +
crm/lib/traffic.ts | 40 +++++-
crm/test/traffic.test.ts | 6 +-
src/app/mcp/page.tsx | 4 +-
src/components/ai-brief-block.tsx | 4 +-
src/components/citation-bar.tsx | 2 +
src/components/copy-button.tsx | 7 +
src/components/embed-badge-button.tsx | 1 +
src/components/posthog-provider.tsx | 34 +++++
src/components/public-endpoints-section.tsx | 2 +-
src/components/search/search-dialog.tsx | 4 +
src/lib/analytics.ts | 28 ++++
14 files changed, 279 insertions(+), 14 deletions(-)
create mode 100644 crm/app/actions/page.tsx
create mode 100644 src/lib/analytics.ts
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..4792bac21
--- /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 (left for a provider or another site)",
+ copy: "Copies (endpoint, API URL, MCP URL, embed)",
+ search: "Searches with a result picked",
+};
+
+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 ? (
+
+
+
+ | Host |
+ Clicks |
+ w/w |
+ Visitors |
+ From |
+
+
+
+ {outbound.map((o) => (
+
+ | {o.host} |
+ {fmtInt(o.clicks)} |
+
+
+ |
+ {fmtInt(o.visitors)} |
+
+ {o.topPage}
+ |
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+
What gets copied, 7 d
+ {copies.length > 0 ? (
+
+
+
+ | Kind |
+ Value |
+ Bench |
+ Copies |
+
+
+
+ {copies.map((c, i) => (
+
+ | {c.kind} |
+
+ {c.value || "–"}
+ |
+
+ {c.bench ? (
+
+ {c.bench}
+
+ ) : (
+ "–"
+ )}
+ |
+ {fmtInt(c.count)} |
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+
+
+ What people search for, 7 d (a result was picked)
+ {searches.length > 0 ? (
+
+
+
+ | Query |
+ Times |
+ Kind |
+ Landed on |
+
+
+
+ {searches.map((s) => (
+
+ | {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..fc1d43364 100644
--- a/crm/lib/traffic.ts
+++ b/crm/lib/traffic.ts
@@ -1,6 +1,6 @@
/**
* 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).
*
@@ -13,6 +13,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 +22,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 +51,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 +129,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 +193,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..8486b39be 100644
--- a/src/app/mcp/page.tsx
+++ b/src/app/mcp/page.tsx
@@ -151,7 +151,7 @@ export default async function McpPage() {
{MCP_URL}
-
+
@@ -344,7 +344,7 @@ function CodeBlock({ value }: { value: string }) {
{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/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..d290a3ccd
--- /dev/null
+++ b/src/lib/analytics.ts
@@ -0,0 +1,28 @@
+/**
+ * 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 / 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" | "embed" | "brief" | "other"; value?: string; bench?: string } }
+ | { name: "search"; props: { query: string; kind: string; url: string } };
+
+export function track(name: E["name"], props: E["props"]): void {
+ if (typeof window === "undefined") return;
+ try {
+ if (!posthog.__loaded) return;
+ posthog.capture(name, props);
+ } catch {
+ // analytics never breaks the page
+ }
+}
From bd2259c78714c2e07fb3a45cae31c9c720503e75 Mon Sep 17 00:00:00 2001
From: Flotapponnier
Date: Sun, 20 Sep 2026 13:25:20 +0200
Subject: [PATCH 2/2] posthog events, review: config snippets on /mcp tagged
mcp_config with their name, the badges catalog copy tagged embed with format
and bench, track() typed per event, action labels rendered
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01HJgbZCqjR4nvCfcJSzofbw
---
crm/app/actions/page.tsx | 8 ++++----
crm/lib/traffic.ts | 3 ++-
src/app/mcp/page.tsx | 10 +++++-----
src/components/badges-catalog.tsx | 1 +
src/lib/analytics.ts | 7 ++++---
5 files changed, 16 insertions(+), 13 deletions(-)
diff --git a/crm/app/actions/page.tsx b/crm/app/actions/page.tsx
index 4792bac21..8384e0810 100644
--- a/crm/app/actions/page.tsx
+++ b/crm/app/actions/page.tsx
@@ -6,9 +6,9 @@ export const dynamic = "force-dynamic";
const SITE = `https://${process.env.SITE_HOST ?? "openchainbench.com"}`;
const ACTION_LABEL: Record = {
- outbound_click: "Outbound clicks (left for a provider or another site)",
- copy: "Copies (endpoint, API URL, MCP URL, embed)",
- search: "Searches with a result picked",
+ outbound_click: "Outbound clicks",
+ copy: "Copies",
+ search: "Searches",
};
export default async function ActionsPage({ searchParams }: { searchParams: Promise<{ refresh?: string }> }) {
@@ -28,7 +28,7 @@ export default async function ActionsPage({ searchParams }: { searchParams: Prom
return (
{" "}
(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 (
);
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/lib/analytics.ts b/src/lib/analytics.ts
index d290a3ccd..eb36ae785 100644
--- a/src/lib/analytics.ts
+++ b/src/lib/analytics.ts
@@ -4,7 +4,7 @@
* 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 / embed / brief
+ * 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
@@ -14,10 +14,11 @@ 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" | "embed" | "brief" | "other"; value?: string; bench?: 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 } };
-export function track(name: E["name"], props: E["props"]): void {
+/** 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;