@boringprojects/analytics
v0.1.0
Published
First-party, ad-blocker-resistant site analytics beacon for BoringProjects sites (pageviews, custom events, web vitals).
Readme
@boringprojects/analytics
First-party, ad-blocker-resistant site analytics for BoringProjects sites — pageviews, custom events, and web vitals, posted same-origin so no third-party hostname or CORS preflight is ever in the critical path.
Every payload is sent via navigator.sendBeacon (falling back to fetch(..., { keepalive: true }))
as Content-Type: text/plain, POSTed to a same-origin base path (default /_bp/v1), which your
site's vercel.json rewrites to the ingest function. See the Site Analytics plan for the full
architecture.
Install
npm install @boringprojects/analytics
# or: pnpm add @boringprojects/analyticsreact (>=18) and next (>=14) are peer dependencies — install them if your project doesn't
already have them (every consumer repo does).
Rewrite (required)
Add this to vercel.json so the beacon's requests stay same-origin (no CORS, no ad-blocker
hostname to match against):
{
"rewrites": [
{ "source": "/_bp/:path*", "destination": "https://<ref>.supabase.co/functions/v1/insights/:path*" }
]
}Usage — single-brand site
Mount <BoringAnalytics /> once, near the root layout:
// app/layout.tsx
import { BoringAnalytics } from '@boringprojects/analytics/next';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<BoringAnalytics siteKey="sk_live_your_site_key" />
</body>
</html>
);
}siteKey is a required prop — it is deliberately not read from an environment variable
(see below).
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| siteKey | string | yes | The public site key. An identifier, not a secret — validated server-side against Origin. |
| basePath | string | no | Same-origin path to POST to. Defaults to /_bp/v1. |
| debug | boolean | no | Log every outgoing payload to the console, and actually send events while NODE_ENV !== 'production' (by default, dev sends nothing). |
| beforeSend | (event) => event \| null | no | Mutate an outgoing payload, or return null to drop it. |
On mount, and again on every route change, <BoringAnalytics /> sends a view with both the
masked route (e.g. /product/[slug]) and the raw path (e.g. /product/abc-123). It also
collects web-vitals (LCP/INP/CLS/TTFB) for the visit and flushes one batched vitals
payload when the tab becomes hidden — not one request per metric.
Usage — multi-brand site (one codebase, several site keys)
Some repos (e.g. patriot-research) build several brand sites from a single codebase. Because
siteKey is a plain prop, each brand's key comes from that brand's own config — not from a
single shared env var, which would be wrong for every brand but one:
// app/layout.tsx
import { BoringAnalytics } from '@boringprojects/analytics/next';
import { getBrandConfig } from '@/lib/brand';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const brand = getBrandConfig(); // resolves the current brand from host/env at request time
return (
<html lang="en">
<body>
{children}
<BoringAnalytics siteKey={brand.analyticsSiteKey} />
</body>
</html>
);
}// lib/brand.ts (sketch)
export const BRANDS = {
'brand-a.com': { analyticsSiteKey: 'sk_live_brand_a', /* ... */ },
'brand-b.com': { analyticsSiteKey: 'sk_live_brand_b', /* ... */ },
// ...four more
} as const;Custom events
import { track } from '@boringprojects/analytics';
track('signup_started', { plan: 'pro', source: 'pricing_page' });Requires <BoringAnalytics /> to already be mounted (it configures the site key/base path this
function posts to). properties must be flat scalars (string | number | boolean | null) —
nested objects/arrays throw in development and are silently stripped in production, so a bad
call never breaks the page for a real visitor.
Attaching a summable value
A property named value is special: ingest lifts it out of props into the dedicated
an_event.value column, and the hourly rollup sums it into an_daily_event.sum_value. That
column is how traffic gets joined to revenue — the reason this pipeline is first-party rather
than an off-the-shelf analytics install.
track('purchase', { value: 4900, currency: 'USD', plan: 'pro' }); // centsUse integer cents, not dollars — the column is numeric, but keeping one unit across every
brand is what makes the totals comparable. valueCents is accepted as an alias for the same
column. Any other key (amount, revenue, total) is stored in props but not summed,
so it will read as zero revenue rather than as a mistake.
Server-side events
For a Next.js route handler or server action — anywhere a fetch can't be ad-blocked and doesn't
have a siteKey/browser context automatically:
import { track } from '@boringprojects/analytics/server';
await track('checkout_completed', { valueCents: 4900 }, {
siteKey: 'sk_live_your_site_key',
ingestUrl: process.env.BORING_ANALYTICS_INGEST_URL!, // e.g. https://<ref>.supabase.co/functions/v1/insights
apiKey: process.env.BORING_ANALYTICS_API_KEY!, // server-only secret — never NEXT_PUBLIC_
});This posts JSON directly to ingestUrl (not through the /_bp/v1 rewrite) with a real secret
apiKey header. It never throws — failures are swallowed and logged with console.error,
matching the fire-and-forget style of boring-service's notification client.
Payload shapes
Every payload carries k (site key), sdkv (SDK version — see src/version.ts), and ts
(client timestamp, ms).
POST /_bp/v1/view—{ k, sdkv, ts, route, path }POST /_bp/v1/event—{ k, sdkv, ts, name, props? }POST /_bp/v1/vitals—{ k, sdkv, ts, route, path, lcp?, inp?, cls?, ttfb? }(one per visit)
Why not application/json?
sendBeacon/fetch bodies are sent as Content-Type: text/plain on purpose. That keeps every
request a CORS-simple request, so no preflight OPTIONS round trip ever fires — load-bearing
for a beacon that must never block or slow down the page it's measuring.
SDK version drift
sdkv is required on every payload and inlined at build time (src/version.ts). Bump it by
hand whenever package.json's version changes. The ingest function records the last-seen
sdkv per site so a stale install shows up on /admin/analytics instead of quietly producing a
data gap for months (this happened five times over with the copy-pasted boring-notify.ts).
