npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/analytics

react (>=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' }); // cents

Use 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).