Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,40 @@ jobs:
- run: pnpm lint
- run: pnpm test
- run: pnpm build

# crm/ is its own package (Railway); the root typecheck excludes it, so it
# gets its own check. Runs only when the app changes.
crm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: changed
with:
filters: |
crm:
- 'crm/**'
- uses: pnpm/action-setup@v4
if: steps.changed.outputs.crm == 'true'
with:
version: 10
- uses: actions/setup-node@v4
if: steps.changed.outputs.crm == 'true'
with:
node-version: 22
- uses: oven-sh/setup-bun@v2
if: steps.changed.outputs.crm == 'true'
with:
bun-version: latest
- run: pnpm install --frozen-lockfile --ignore-workspace
if: steps.changed.outputs.crm == 'true'
working-directory: crm
- run: pnpm typecheck
if: steps.changed.outputs.crm == 'true'
working-directory: crm
- run: pnpm test
if: steps.changed.outputs.crm == 'true'
working-directory: crm
- run: pnpm build
if: steps.changed.outputs.crm == 'true'
working-directory: crm
18 changes: 18 additions & 0 deletions crm/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Shared password (16+ chars) and a random secret (16+ chars) that signs session cookies.
# Rotating either logs everyone out.
CRM_PASSWORD=
CRM_SESSION_SECRET=
# PostHog personal API key with query:read on the OpenChainBench project, and the project id.
POSTHOG_PERSONAL_API_KEY=
POSTHOG_PROJECT_ID=
POSTHOG_HOST=https://us.posthog.com
# Host the site is served from; events from staging/localhost are excluded.
SITE_HOST=openchainbench.com
# HogQL queries the app may spend per rolling hour (PostHog allows 2400/hour per organisation).
POSTHOG_HOURLY_BUDGET=300
# Minutes between two automatic refreshes of the snapshot.
REFRESH_MINUTES=60
# Directory the snapshot and its daily history are written to (a Railway volume in production).
SNAPSHOT_DIR=/data
# Optional: Dune API key, to show credits left on the plan.
DUNE_API_KEY=
6 changes: 6 additions & 0 deletions crm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
.next
.snapshots
.env.local
*.tsbuildinfo
next-env.d.ts
4 changes: 4 additions & 0 deletions crm/.railwayignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
.next
.snapshots
.env.local
26 changes: 26 additions & 0 deletions crm/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build

FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
# Runs as root: the Railway volume at /data is mounted root-owned and the
# platform offers no fsGroup; the container holds one password and read keys.
RUN mkdir -p /data
EXPOSE 3210
CMD ["node", "server.js"]
94 changes: 94 additions & 0 deletions crm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# OCB CRM

Internal dashboard for OpenChainBench: traffic (PostHog), data health (the
worker's index blob), harness health (Prometheus targets) and the Dune plan.
One shared password, per-login sessions, one Railway service, no database.

## What it shows

| Page | Source | Numbers |
|---|---|---|
| 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 |
| 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
localhost are excluded (`properties.$host`).

Bench health reads `aggregate/index.json` (every bench with its status and
last run), not the sitemap blob: the worker drops expired chain RPC benches
from the sitemap before publishing, which is exactly what this page must show.

## How it stays under the PostHog rate limit

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
(`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
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
second guard; a 429 or an exhausted budget stops the batch, the sections that
did not run keep their previous values, and the next scheduled refresh
retries.

A section that fails keeps its previous value and shows its error in the
header, so an upstream blip never blanks the dashboard.

## Run locally

```bash
cd crm
pnpm install --ignore-workspace
cp .env.example .env.local # fill CRM_PASSWORD, CRM_SESSION_SECRET, POSTHOG_*, optionally DUNE_API_KEY
set -a; source .env.local; set +a
SNAPSHOT_DIR=.snapshots pnpm refresh # one refresh from the CLI
SNAPSHOT_DIR=.snapshots pnpm dev # http://localhost:3210
pnpm test && pnpm typecheck
```

## Deploy (Railway)

The service is `ocb-crm` in the Railway project `Dashboard OpenChainBench`,
built from `crm/Dockerfile`, with a volume mounted at `/data` for the snapshot
and its history.

```bash
cd crm
railway link # project Dashboard OpenChainBench, service ocb-crm
railway up --detach # uploads this directory, builds the Dockerfile
railway logs
```

Variables (Railway service settings): `CRM_PASSWORD`, `CRM_SESSION_SECRET`, `POSTHOG_PERSONAL_API_KEY`,
`POSTHOG_PROJECT_ID`, optionally `DUNE_API_KEY`, `POSTHOG_HOURLY_BUDGET`,
`REFRESH_MINUTES`. `SNAPSHOT_DIR=/data` and `PORT` are set on the service.

## Sessions

The cookie is `nonce.expiry.signature`, signed with `CRM_SESSION_SECRET` (random,
not the password, so a leaked cookie gives nothing to brute force) and valid
only while its nonce is listed in `/data/sessions.json`: logout revokes it,
rotating either variable logs everyone out. Login attempts are limited to 10
per client per 15 minutes. Both variables must be 16 characters or more.

Module state (snapshot cache, refresh mutex, PostHog budget, login counters)
lives on `globalThis` and the snapshot file is re-read whenever its mtime
moves: Next bundles `instrumentation.ts` and the routes in different layers,
and each layer gets its own module instance otherwise.

## Adding a metric

1. Add a query to `QUERIES` in `lib/traffic.ts` and its mapping in
`loadTrafficSection`; the refresh budget grows by one query per hour.
2. Extend the `Traffic` type, render it on a page.
3. `pnpm test`: the query test checks every query stays scoped to
`$pageview` on the production host.

Non-PostHog sources go in `lib/ocb.ts` and get a `step()` in `lib/snapshot.ts`.
23 changes: 23 additions & 0 deletions crm/app/api/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { NextRequest } from "next/server";
import { authConfigured, clientKey, COOKIE, issueSession, loginAllowed, passwordMatches, recordLoginAttempt, seeOther, SESSION_DAYS } from "@/lib/auth";

// A small fixed delay per attempt on top of the per-client limit.
const ATTEMPT_DELAY_MS = 600;

export async function POST(request: NextRequest) {
const form = await request.formData();
const password = String(form.get("password") ?? "");
const nextPath = String(form.get("next") ?? "/");
const back = (error: string) => seeOther(request, `/login?error=${error}${nextPath !== "/" ? `&next=${encodeURIComponent(nextPath)}` : ""}`);
const key = clientKey(request);
if (!loginAllowed(key)) return back("limited");
recordLoginAttempt(key);
await new Promise((r) => setTimeout(r, ATTEMPT_DELAY_MS));
if (!authConfigured() || !(await passwordMatches(password))) return back("1");
const res = seeOther(request, nextPath);
const token = await issueSession();
const attrs = [`${COOKIE}=${token}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${SESSION_DAYS * 86_400}`];
if (process.env.NODE_ENV === "production") attrs.push("Secure");
res.headers.append("Set-Cookie", attrs.join("; "));
return res;
}
9 changes: 9 additions & 0 deletions crm/app/api/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { NextRequest } from "next/server";
import { COOKIE, revokeSession, seeOther } from "@/lib/auth";

export async function POST(request: NextRequest) {
await revokeSession(request.cookies.get(COOKIE)?.value);
const res = seeOther(request, "/login");
res.headers.append("Set-Cookie", `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
return res;
}
24 changes: 24 additions & 0 deletions crm/app/api/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { NextRequest } from "next/server";
import { seeOther } from "@/lib/auth";
import { MANUAL_COOLDOWN_MINUTES, readSnapshot, refreshSnapshot, snapshotAgeMinutes } from "@/lib/snapshot";

// Manual refresh, behind the session cookie (proxy.ts) and a cooldown: the
// button cannot become a way to spend the PostHog budget.
export async function POST(request: NextRequest) {
const snap = await readSnapshot();
const age = snapshotAgeMinutes(snap);
// Back to the page the button was on; only the path of the referer is kept.
let back = "/";
try {
const ref = new URL(request.headers.get("referer") ?? "", "http://x");
back = ref.pathname.startsWith("/") ? ref.pathname : "/";
} catch {
// fall through to the overview
}
if (age != null && age < MANUAL_COOLDOWN_MINUTES) {
return seeOther(request, `${back}?refresh=cooldown:${Math.ceil(MANUAL_COOLDOWN_MINUTES - age)}`);
}
const result = await refreshSnapshot("manual");
const flag = result.joined ? "joined" : result.stoppedBy ? "partial" : result.failed.length ? "errors" : "ok";
return seeOther(request, `${back}?refresh=${flag}`);
}
8 changes: 8 additions & 0 deletions crm/app/api/snapshot/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { readHistory, readSnapshot } from "@/lib/snapshot";

// The raw snapshot and history, for a spreadsheet or a script. Session cookie required.
export async function GET() {
const [snapshot, history] = await Promise.all([readSnapshot(), readHistory()]);
return NextResponse.json({ snapshot, history }, { headers: { "Cache-Control": "private, no-store" } });
}
93 changes: 93 additions & 0 deletions crm/app/audience/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Shell } from "@/components/shell";
import { Bars, Delta, Empty, fmtInt, fmtPct, Kpi } from "@/components/ui";
import { readSnapshot } from "@/lib/snapshot";

export const dynamic = "force-dynamic";

const CHANNEL_LABEL = { ai: "AI", search: "Search", social: "Social", direct: "Direct", referral: "Referral", internal: "Internal" } as const;

export default async function AudiencePage({ searchParams }: { searchParams: Promise<{ refresh?: string }> }) {
const [snap, sp] = await Promise.all([readSnapshot(), searchParams]);
const t = snap.traffic;
const a = t.audience;
const total = a ? a.newVisitors + a.returningVisitors : 0;
const referrers = (t.referrers ?? []).filter((r) => r.channel !== "direct" && r.channel !== "internal" && (r.visitors > 0 || r.prevVisitors > 0)).slice(0, 40);

return (
<Shell current="/audience" snapshot={snap} refreshFlag={sp.refresh}>
<section className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Kpi label="New visitors, 7 d" value={fmtInt(a?.newVisitors)} sub={a && total > 0 ? `${fmtPct(a.newVisitors / total)} of active` : undefined} />
<Kpi label="Returning visitors, 7 d" value={fmtInt(a?.returningVisitors)} sub={a && total > 0 ? `${fmtPct(a.returningVisitors / total)} of active` : "first seen before the window"} />
<Kpi label="Sessions, 7 d" value={fmtInt(t.engagement?.sessions)} />
<Kpi label="Bounce rate" value={fmtPct(t.engagement?.bounceRate)} sub="single-pageview sessions" />
</section>

<section className="mt-6 grid gap-3 md:grid-cols-2">
<div className="panel p-4">
<p className="label">Countries, 7 d</p>
{t.countries && t.countries.length > 0 ? (
<Bars rows={t.countries.slice(0, 15).map((c) => ({ label: c.name, value: c.visitors, hint: `· ${fmtPct(c.share)}` }))} />
) : (
<Empty text="No geo data yet." />
)}
</div>
<div className="panel p-4">
<p className="label">Devices, 7 d</p>
{t.devices && t.devices.length > 0 ? (
<Bars rows={t.devices.map((d) => ({ label: d.name, value: d.visitors, hint: `· ${fmtPct(d.share)}` }))} />
) : (
<Empty text="No device data yet." />
)}
<p className="label mt-6">UTM sources, 7 d</p>
{t.utm && t.utm.length > 0 ? (
<table className="data mt-2">
<tbody>
{t.utm.map((u) => (
<tr key={`${u.source}/${u.medium}`}>
<td className="mono">{u.source}</td>
<td style={{ color: "var(--muted)" }}>{u.medium}</td>
<td className="num mono">{fmtInt(u.visitors)}</td>
</tr>
))}
</tbody>
</table>
) : (
<Empty text="No tagged campaign traffic in the window." />
)}
</div>
</section>

<section className="panel mt-6 p-4">
<p className="label">Referring domains, 7 d (direct and internal excluded)</p>
{referrers.length > 0 ? (
<table className="data mt-2">
<thead>
<tr>
<th>Domain</th>
<th>Channel</th>
<th className="num">Visitors</th>
<th className="num">w/w</th>
<th className="num">Pageviews</th>
</tr>
</thead>
<tbody>
{referrers.map((r) => (
<tr key={r.domain}>
<td className="mono">{r.domain}</td>
<td style={{ color: r.channel === "ai" ? "var(--good)" : "var(--muted)" }}>{CHANNEL_LABEL[r.channel]}</td>
<td className="num mono">{fmtInt(r.visitors)}</td>
<td className="num mono">
<Delta now={r.visitors} prev={r.prevVisitors} />
</td>
<td className="num mono">{fmtInt(r.pageviews)}</td>
</tr>
))}
</tbody>
</table>
) : (
<Empty text="No referrer data yet." />
)}
</section>
</Shell>
);
}
33 changes: 33 additions & 0 deletions crm/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
@import "tailwindcss";

:root {
--bg: #0b0d10;
--panel: #12151a;
--line: #22272f;
--ink: #e6e8eb;
--muted: #8b939e;
--faint: #5c6673;
--accent: #7c8cff;
--good: #3ecf8e;
--warn: #f5b942;
--bad: #f0625d;
}

html { color-scheme: dark; }
body {
background: var(--bg);
color: var(--ink);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
}
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; }
.label { font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); }
table.data { width: 100%; border-collapse: collapse; }
table.data th { text-align: left; font-weight: 500; color: var(--muted); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; padding: 6px 8px; border-bottom: 1px solid var(--line); }
table.data td { padding: 6px 8px; border-bottom: 1px solid color-mix(in srgb, var(--line) 60%, transparent); vertical-align: top; }
table.data td.num, table.data th.num { text-align: right; font-variant-numeric: tabular-nums; }
a.nav { color: var(--muted); padding: 6px 10px; border-radius: 6px; }
a.nav:hover { color: var(--ink); background: var(--panel); }
a.nav[aria-current="page"] { color: var(--ink); background: var(--panel); border: 1px solid var(--line); }
.up { color: var(--good); } .down { color: var(--bad); } .flat { color: var(--faint); }
Loading
Loading