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

bq-analytics

v0.8.7

Published

Tiny analytics SDK that sends events directly to BigQuery. PostHog/Segment-shaped API, ~$0/month at indie scale, queryable with bq CLI.

Readme

bq-analytics is a tiny analytics SDK that pipes events directly to BigQuery — so your AI agent can answer "how is the product doing?" by querying real data, not eyeballing a dashboard.

pnpm add bq-analytics

Capabilities

  • AI-native by design. bq query is the interface. Claude / Cursor / any agent can read your real product data, run conversion funnels, and debug user issues — no hosted dashboard, no proprietary query language.
  • ~$0/mo at indie scale. 5M events/month fits inside BigQuery's free tiers. PostHog Cloud at the same volume is ~$153/mo. Your data lives in your own GCP project — migrate to ClickHouse / DuckDB / Tinybird with one bq extract.
  • One SDK, every runtime. Next.js (Vercel), Express / Hono / Fastify, Expo / React Native, browser, Node CLI — same track / identify / group / log / feedback shape, same BigQuery schema.
  • No queue infra to run. Browser and RN persist failed batches locally; Vercel Log Drain retries server logs at-least-once. Server-side track() durability matches PostHog / Segment / Amplitude — see the operations details below.
  • Feature flags + release config built in. Edge Config-backed flags and Expo force-update / what's-new prompts ship in the same package. Exposures auto-track for impact analysis.
  • No service-account keys. Vercel OIDC → GCP Workload Identity Federation. No JSON keys to rotate.

Tip: once installed, ask your agent "what can bq-analytics do?" — the bundled Claude skill walks it through.

Quickstart

The fastest path is via the Claude Code marketplace — Claude drives the install, detects your runtime, wires the route handlers, and tells you what to verify.

/plugin marketplace add johnkueh/bq-analytics
/plugin install bq-analytics@bq-analytics
/bq-analytics-install
pnpm add bq-analytics

# One-shot per project: BQ datasets + tables, Vercel OIDC, IAM bindings, log drain
TEAM_SLUG=acme PROJECT_NAME=my-app PROJECT_DOMAIN=www.example.com \
  VERCEL_TOKEN=... \
  ./node_modules/bq-analytics/scripts/setup-bq-oidc.sh --gcp my-gcp-project

Then in your Next.js app:

// src/app/api/track/route.ts
export { POST } from "bq-analytics/next/track-route";

// src/app/api/internal/log-drain/route.ts
export { POST } from "bq-analytics/next/log-drain-route";

// anywhere in server code
import { Analytics, bqTransport } from "bq-analytics";
const a = new Analytics({ transport: bqTransport({ projectId: "..." }) });
a.track("translation.started", { videoId: "abc" }, { userId: "u1" });
a.identify("u1", { plan: "pro", credits: 47 });
await a.flush();

Wide-event scopes

For multi-step orchestrations (HTTP request, CLI command, client flow), open a scope, accumulate context, end with one row in logs.raw:

import { withScope } from "bq-analytics";

await withScope(a, { source: "process", fields: { pendingId, householdId } }, async (scope) => {
  scope.set({ sourceType: "url", cacheChecked: true });
  const result = await doWork();
  scope.set({ outcome: "success", recipeId: result.id });
});
// → one logs.raw row, source="process", fields contains everything plus duration_ms
// On throw: scope ends automatically with level="error" and error_message/error_stack.

Query later by any field without joins: WHERE source = 'process' AND JSON_VALUE(fields.outcome) = 'success'.

What you can ask your agent

Once events.* and logs.* are flowing, an AI agent can answer real product questions directly. Examples that map to a single BigQuery query:

| Question | Tables joined | |---|---| | "Funnel from signup to first purchase last 7 days, split by plan" | events.raw + events.users | | "Which Pro yearly users hit upload errors today?" | events.raw + events.users + logs.raw | | "What did this user see when they reported the bug?" | events.feedback + events.raw + events.users | | "Did the new-checkout flag move conversion?" | events.raw (filtered on $flag_called) | | "Show me the last 30 minutes of errors on the /translate route" | logs.raw |

The bundled claude-skills/query/SKILL.md gives agents prompt-shaped guidance for these joins. No dashboard, no SaaS billing — just SQL the agent already knows how to write.

Modules

| Module | What it adds | Required? | |---|---|---| | bq-analytics | Core SDK — track / identify / group / log / feedback / scope + bqTransport / httpTransport | ✅ | | bq-analytics/next | Next.js route handlers (/api/track, log drain, flags, release config) | for Next | | bq-analytics/pino | pino transport for Express / Fastify / Hono / raw Node | for non-Next | | bq-analytics/logger | createLogger(analytics)console-shaped wrapper around analytics.log() for server code that wants log lines in BQ without a Vercel Log Drain | optional | | bq-analytics/browser | browserTransport, attachBrowserAutoFlush, attachWindowErrorHandler | for web | | bq-analytics/react-native | reactNativeTransport, attachExpoErrorHandler, attachAppStateFlush | for RN/Expo | | bq-analytics/cli | attachCliHooks — uncaught + unhandled + SIGINT/SIGTERM | for CLI | | bq-analytics/edge-config + bq-flags | Feature flags + CLI | optional | | bq-analytics/release/native | Force-update gate + what's-new + pending-update prompts for Expo | optional |

Setup by stack

// src/app/api/track/route.ts
import { createTrackRoute, cachedResolver } from "bq-analytics/next";
export const POST = createTrackRoute({
  projectId: process.env.GCP_PROJECT_ID!,
  // Caching is strongly recommended — every analytics POST otherwise pays
  // a DB round-trip to map the auth token to a user id. See "resolveUser
  // caching" below for why this matters.
  resolveUser: cachedResolver(
    (req) => req.headers.get("authorization")?.slice(7),
    async (token) => /* your DB lookup */ null,
  ),
});

// src/app/api/internal/log-drain/route.ts
//
// Edge runtime is strongly recommended — Vercel's `lambda` source emits
// START / END / REPORT lines for every function call, which the drain
// then ships back to itself. `edge` runtime does not emit those lines, so
// the loop dies at the source. See `createLogDrainRoute` JSDoc.
export const runtime = "edge";

import { createLogDrainRoute } from "bq-analytics/next";
export const { POST, GET } = createLogDrainRoute({
  projectId: process.env.GCP_PROJECT_ID!,
  secret: process.env.LOG_DRAIN_SECRET!,
  vercelVerifyToken: process.env.VERCEL_VERIFY_TOKEN,
});

// src/lib/analytics.ts — server singleton
import { Analytics, bqTransport } from "bq-analytics";
declare global { var __bqa: Analytics | undefined; }
export function analytics() {
  return globalThis.__bqa ??= new Analytics({
    transport: bqTransport({ projectId: process.env.GCP_PROJECT_ID! }),
  });
}

// in any route handler
import { flushAfter } from "bq-analytics/next";
flushAfter(analytics);
analytics().track("foo", { ... }, { userId });

flushAfter schedules analytics.flush() to run after the response is sent (via Next's after()waitUntil). Without it, buffered records can be lost when a serverless instance is recycled. Use it in every Next route handler that emits events; bq-analytics/hono's honoFlushMiddleware covers the same ground for Hono apps.

The setup script provisions the Vercel Log Drain pointed at /api/internal/log-drain automatically.

The drain is the right path when you can't intercept stdout from third-party code that uses console.* directly. For your own server code, bq-analytics/logger is usually cheaper — every Vercel function invocation triggers a drain HTTP-proxy event, so at non-trivial traffic the drain itself becomes the largest line item in your Vercel function-invocations bill. Direct emission via logger.info(...) skips that entirely:

// src/lib/logger.ts
import { createLogger } from "bq-analytics/logger";
import { analytics } from "./analytics"; // returns Analytics singleton
export const logger = createLogger(analytics, { source: "lambda" });

logger.info("[submit] accepted", { url, pending_id }) writes both to stdout (still visible in vercel logs for live tail) and to logs.raw via the same transport that handles events.raw.

import pino from "pino";
import { pinoBqTransport } from "bq-analytics/pino";
import { Analytics, bqTransport } from "bq-analytics";

const a = new Analytics({ transport: bqTransport({ projectId }) });
const logger = pino({}, pinoBqTransport({ projectId, analytics: a, source: "api" }));

// Express
import pinoHttp from "pino-http";
app.use(pinoHttp({ logger }));               // every request → logs.raw
app.post("/checkout", async (req, res) => {
  a.track("checkout.started", { plan: "pro" }, { userId: req.userId });
  res.json({ ok: true });
});

// Graceful shutdown — flush before SIGTERM kills you
process.on("SIGTERM", async () => { await a.flush(); process.exit(0); });

Hono uses the same pattern with hono/logger. Fastify accepts logger directly via Fastify({ logger }).

import { Analytics } from "bq-analytics";
import {
  browserTransport,
  attachBrowserAutoFlush,
  attachWindowErrorHandler,
} from "bq-analytics/browser";

const a = new Analytics({ transport: browserTransport({ url: "/api/track" }) });
attachBrowserAutoFlush(() => a.flush());   // flush on pagehide / visibilitychange
attachWindowErrorHandler(a);               // uncaught + unhandledrejection → logs.raw

a.track("page.viewed", { path: location.pathname });
import { Analytics } from "bq-analytics";
import {
  reactNativeTransport,
  attachExpoErrorHandler,
  attachAppStateFlush,
} from "bq-analytics/react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { AppState, Platform } from "react-native";

const a = new Analytics({
  transport: reactNativeTransport({
    url: `${API_URL}/api/track`,
    headers: { authorization: `Bearer ${deviceToken}` },
    storage: AsyncStorage,
  }),
});

// Pass attrs as a getter when userId loads asynchronously
let currentUserId: string | undefined;
attachExpoErrorHandler(a, ErrorUtils, () => ({ platform: Platform.OS, userId: currentUserId }));
attachAppStateFlush(a, AppState, () => ({ userId: currentUserId }));

a.track("import.started", { source: "instagram" }, { userId: currentUserId });

Recommended identify() traits for Expo apps — include platform, app_version, build_number, ota_update_id, ota_channel, runtime_version. The ota_update_id is the only honest answer to "but I OTA'd!". events.users merges traits per-key with latest-write-wins, so the next OTA's identify({ota_update_id: ...}) updates only the keys you pass and leaves the rest untouched. Calling identify(userId, {}) is safe — empty traits never wipe existing keys; same for group(type, id, {}, userId) when the intent is membership only.

import { Analytics, bqTransport } from "bq-analytics";
import { attachCliHooks } from "bq-analytics/cli";

const a = new Analytics({ transport: bqTransport({ projectId }) });
attachCliHooks(a, { source: "my-cli" });   // uncaught + unhandled + SIGINT/SIGTERM

a.track("cli.command_run", { command: process.argv[2] });
await a.flush();   // CRITICAL: process exits the moment you return

If your CLI talks to a hosted product, swap bqTransport for httpTransport — same SDK, events go through /api/track with an API key.

There's no native SDK. POST events directly to your /api/track route from any HTTP client. The schema is { records: [{ kind: "event", row: {...} }, ...] } — see src/types.ts for the row shapes.

Architecture

                                       BigQuery (your GCP project)
                                       ┌──────────────────────────┐
                                       │ events.raw                │
                                       │ events.identifies         │
browser SDK ─┐                         │ events.groups             │
RN/Expo SDK ─┼─ POST /api/track ──────▶│ events.user_groups        │
CLI scripts ─┘                         │   + views: events.users,  │
server SDK ─── direct insertAll ──────▶│           groups_current  │
                                       │                           │
Vercel Log ──── /api/internal/         │ logs.raw                  │
Drain          log-drain ─────────────▶│                           │
                                       └──────────────────────────┘
                                                 ▲
                                                 │  bq query  (CLI / Claude)

Two pipelines: events.* (explicit product events from any client, JSON property column → never migrate) and logs.* (implicit Vercel runtime captures via Log Drain — replaces Vercel's 1–3 day log retention with your BQ partition policy).

Cost (5M events/month, indie scale)

| Component | $/month | |---|---| | BigQuery streaming ingest | $0 (under 2 TiB free tier) | | BigQuery storage | ~$0.03 (60 GB active × $0.02/GiB) | | BigQuery queries | $0 (under 1 TB free) | | Vercel function — /api/track (5M × 10 ms) | ~$0.20 | | Vercel function — drain handler (~5k batches × 50 ms) | ~$0.01 | | Vercel Observability log overage | ~$0.13 (1.25 GB × $0.50/GiB after 1 GB free) | | Vercel Log Drain delivery | $0 (Pro included) | | Total | ~$0.40 / mo |

PostHog Cloud at 5M: ~$153/mo. ~400× cheaper. Want PostHog's UI and replays? Use PostHog. Want event analytics + flags an AI agent can query and operate? This.

events.raw           event_id, ts, event_name, user_id, anonymous_id, session_id, properties JSON
events.identifies    ts, user_id, traits JSON
events.groups        ts, group_type, group_id, traits JSON
events.user_groups   ts, user_id, group_type, group_id

events.users               ── view: per-key merged traits per user_id (latest value per key wins)
events.groups_current      ── view: per-key merged traits per (group_type, group_id) (latest value per key wins)
events.user_groups_current ── view: most-recent group per user/type

events.feedback      feedback_id, ts, kind, subject, message, severity, url,
                     user_id, anonymous_id, session_id, properties JSON

logs.raw             ts, level, source, message, fields JSON, request_id, deployment_id, path, status, region, raw

All tables partition by DATE(ts) and cluster on common filter columns. Custom traits/properties go in JSON columns — never alter schema for a new field.

# events
bq query --nouse_legacy_sql --format=json '
  SELECT event_name, COUNT(*) AS n
  FROM `proj.events.raw` WHERE DATE(ts) > CURRENT_DATE() - 7
  GROUP BY 1 ORDER BY n DESC'

# pro yearly users → translation.completed conversion
bq query --nouse_legacy_sql --format=json '
  SELECT COUNT(*) FROM `proj.events.raw` e
  JOIN `proj.events.users` u USING (user_id)
  WHERE e.event_name = "translation.completed"
    AND JSON_VALUE(u.traits, "$.plan") = "pro"
    AND JSON_VALUE(u.traits, "$.plan_period") = "yearly"'

# bug reports from pro users in the last week
bq query --nouse_legacy_sql --format=json '
  SELECT f.subject, f.message, JSON_VALUE(u.traits, "$.email") AS email
  FROM `proj.events.feedback` f
  LEFT JOIN `proj.events.users` u USING (user_id)
  WHERE f.kind = "bug" AND DATE(f.ts) > CURRENT_DATE() - 7
    AND JSON_VALUE(u.traits, "$.plan") = "pro"
  ORDER BY f.ts DESC'

# replace `vercel logs --query`
bq query --nouse_legacy_sql --format=json '
  SELECT ts, level, path, status, message FROM `proj.logs.raw`
  WHERE ts > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 MINUTE)
    AND CONTAINS_SUBSTR(message, "beacon")
  ORDER BY ts DESC LIMIT 50'

One method writes structured feedback into a dedicated events.feedback table — joinable with events.users and events.raw on user_id, so an agent has a single query for "this user said the upload broke; what was actually happening at that moment?"

analytics.feedback(
  {
    kind: "bug",                          // "bug" | "request" | "general" | "email" | "bounce" | "complaint" | (custom)
    subject: "Translate button does nothing",
    message: "After uploading a video, the Translate button is unresponsive.",
    severity: "high",
    url: "/translate",
    properties: { app_version: "1.4.2", platform: "ios" },
  },
  { userId, sessionId },
);

Same intake as track/identify/group — buffered and flushed via the same lifecycle. Browser/RN submissions ride /api/track; server and CLI write direct via bqTransport. Anonymous submissions accepted (omit userId).

This is intake + warehouse, not a helpdesk. No inbox UI, no threading, no status mutation — those belong in Linear/Plain/Pylon. The point here is "Claude has the full story when investigating."

WITH f AS (
  SELECT * FROM `proj.events.feedback`
  WHERE DATE(ts) > CURRENT_DATE() - 7 AND kind = 'bug'
)
SELECT
  f.feedback_id, f.ts AS reported_at, f.subject, f.message,
  JSON_VALUE(u.traits, '$.plan')        AS plan,
  JSON_VALUE(u.traits, '$.app_version') AS app_version,
  ARRAY(
    SELECT AS STRUCT e.event_name, e.ts
    FROM `proj.events.raw` e
    WHERE e.user_id = f.user_id
      AND e.ts BETWEEN TIMESTAMP_SUB(f.ts, INTERVAL 30 MINUTE) AND f.ts
    ORDER BY e.ts DESC LIMIT 20
  ) AS recent_events
FROM f
LEFT JOIN `proj.events.users` u USING (user_id)
ORDER BY f.ts DESC LIMIT 50;

That single query gives an agent: the bug report, the user's plan + build, and the last 30 minutes of their session. No cross-system stitching.

Backed by Vercel Edge Config; sub-second propagation; ~free at indie scale; exposures auto-track to events.raw.

// src/lib/flags.ts
import { Flags } from "bq-analytics";
import { edgeConfigSource } from "bq-analytics/edge-config";
import { analytics } from "./analytics";

export const flags = new Flags({
  source: edgeConfigSource(),
  analytics: analytics(),         // → emits "$flag_called" exposures
  refreshIntervalMs: 60_000,
});

await flags.ready();
if (flags.isOn("new-checkout", userId)) { /* new flow */ }

Browser/RN clients fetch via your own /api/flags route — never expose the Edge Config token to clients. The bq-analytics/next/flags subpath isolates the @vercel/edge-config import so /api/track stays edge-config-free.

// src/app/api/flags/route.ts
import { createFlagsRoute } from "bq-analytics/next/flags";
export const GET = createFlagsRoute({
  resolveUser: async (req) => /* your auth */ null,
  filter: (flags) => Object.fromEntries(
    Object.entries(flags).map(([k, v]) => [k, { ...v, users: undefined }]),
  ),
});

// browser / RN
import { Flags, httpSource } from "bq-analytics";
const flags = new Flags({ source: httpSource({ url: "/api/flags" }) });
await flags.ready();
flags.isOn("new-checkout", userId);

One-time setup: ./scripts/setup-edge-config.sh provisions the store, mints a token, sets EDGE_CONFIG on Vercel Production, pulls into .env.local. Idempotent.

Operating flags — bq-flags CLI:

bq-flags list                                 # current state
bq-flags on  new-checkout --rollout 25%       # create / turn on at 25%
bq-flags rollout new-checkout 100%            # ramp
bq-flags allow ai-suggestions u_alice u_bob   # allowlist
bq-flags off new-checkout                     # kill switch
bq-flags eval new-checkout --outcome subscription.started

eval runs exposure / lift queries against events.raw. Full operations guide and cohort-materialisation flow in claude-skills/flags/SKILL.md.

Flag config shape — one JSON object under the flags key in Edge Config:

{
  "new-checkout":   { "on": true, "rollout": 0.5 },
  "ai-suggestions": { "on": true, "users": ["u_john", "u_beta1"] },
  "kill-old-flow":  { "on": false }
}

rollout is 0..1 (deterministic FNV-1a hash on userId+key). users allowlist bypasses the rollout.

Server-driven release UX: force-update gate (hard block / soft nudge), post-update what's-new sheet, channel-aware store deeplinks. One opinionated Edge Config blob under the key release. Same store as flags is fine.

// src/app/api/release-config/route.ts (Next.js)
import { createReleaseConfigRoute } from "bq-analytics/next/release";
export const GET = createReleaseConfigRoute();
// Reads `release` from Edge Config, validates, returns JSON with 60s edge cache.
// app/_layout.tsx — headless components, you provide UI via render props
import * as Updates from "expo-updates";
import Constants from "expo-constants";
import { UpdateGate, ReleaseNotesPrompt } from "bq-analytics/release/native";
// Optional — only import this if you want the auto-summoned "Update ready" sheet.
import { PendingUpdatePrompt } from "bq-analytics/release/native/pending-update";

const channel = Updates.channel || (__DEV__ ? "development" : "production");
const releaseTag =
  (Constants.expoConfig?.extra?.releaseTag as string | undefined) ??
  Constants.expoConfig?.version;

<UpdateGate
  iosAppId="123456789"
  androidPackage="com.example.app"
  channel={channel}
  renderHardBlock={({ message, openStore }) => (
    <YourForceUpdateScreen message={message} onUpdate={openStore} />
  )}
>
  <App />
  <ReleaseNotesPrompt
    iosAppId="123456789"
    androidPackage="com.example.app"
    channel={channel}
    appVersion={releaseTag}
    render={(ctx) => <YourWhatsNewSheet {...ctx} />}
  />
  <PendingUpdatePrompt
    render={(ctx) => <YourUpdateReadySheet {...ctx} />}
    silentReloadAfterBackgroundMs={120_000}
  />
</UpdateGate>

ReleaseNotesPrompt ctx: {notes, verdict, visible, onDismiss, onUpdate, onCtaTap}. Verdict ('ok' | 'soft') drives the primary CTA; 'hard' never reaches the sheet (the gate replaces children). Optional appVersion prop suppresses the sheet until the user is on the bundle whose label matches notes.version.

PendingUpdatePrompt ctx: {updateId, visible, onApply, onDismiss, applying}. Auto-fires when an OTA bundle is downloaded but not yet applied; per-bundle dismissal stored in AsyncStorage. Skipped in __DEV__ by default.

Bundle discovery options:

  • Default: cold-start only via expo-updates' checkAutomatically: 'ON_LOAD'. Conservative — never interrupts foreground sessions but slow propagation.
  • silentReloadAfterBackgroundMs={120_000}: when the app returns from background ≥ 2 min, silently checkForUpdateAsync + fetchUpdateAsync + reloadAsync. User perceives the return as a fresh open. Active foreground sessions are never interrupted. Cascade-safe via 60s lastReloadedAt cooldown. See Expo's silent-reload guidance.

One-time setup:

./scripts/setup-edge-config.sh   # if you don't already have an Edge Config store
./scripts/setup-release.sh       # seeds the `release` key with the no-op default

Operating release config — bq-release CLI:

bq-release show                                   # current state
bq-release gate off                               # disable the gate
bq-release gate soft 42                           # nudge users below build 42
bq-release gate hard 42 --message "Critical fix"  # full-screen block
bq-release notes "v1.1.0" --from notes.json       # publish what's-new
bq-release clear-notes
bq-release urls set preview ios "itms-beta://..."

Read-merge-write semantics — partial updates don't blow away other fields. All write commands accept --dry-run. Full operations guide in claude-skills/release/SKILL.md.

Telemetry events — exported as RELEASE_EVENTS from bq-analytics/release:

update_gate.shown          update_gate.feedback_tapped
whats_new.shown            whats_new.dismissed       whats_new.update_tapped
whats_new.feedback_tapped  whats_new.cta_tapped
pending_update.shown       pending_update.applied    pending_update.dismissed

Cohorts slice by app_version / build_number / runtime_version traits on identify. Pending-update events carry update_id for per-bundle apply-rate analysis.

Durability

| Pipeline | In-flight loss on function-termination | Destination (BQ) outage | |---|---|---| | Browser → /api/track | Recovered: failed batches persist to localStorage, retried on next page load | Same as in-flight | | RN/Expo → /api/track | Recovered: failed batches persist to AsyncStorage, retried on next app launch | Same as in-flight | | Server SDK → BQ direct | Possible loss if the function instance dies between buffered track() and next flush(). Mitigation: flushAt: 1 or await flush() (or after(() => flush()) on Vercel) | Possible loss/api/track returns 5xx, no server-side queue | | Vercel Log Drain → handler → BQ | At-least-once: Vercel retries on 5xx | At-least-once: same retry path | | CLI scripts → /api/track | Script process owns retry | If /api/track returns 5xx, the call throws — script can retry |

Server-side gap is identical to PostHog / Segment / Amplitude — verified against their docs and source. None of them ship a Redis/disk durability layer in the SDK. Hosted tools' edge is that their ingest endpoints are Kafka-backed, so an event durably persists even if the analytics DB is down. We don't have that — /api/track writes straight to BQ. BigQuery's published streaming SLA is 99.99%, so practical loss is bounded. If you ever need true at-least-once for revenue-critical events, put a buffer in front: Upstash Redis + a QStash cron flusher (~50 lines, opt-in).

Recommended pattern for server-side track() on Vercel:

// option A: flushAt: 1 — every track() does its own HTTP round-trip
const a = new Analytics({ transport: bqTransport({ projectId }), flushAt: 1 });
a.track("foo", { ... }, { userId });
await a.flush();

// option B (preferred): batch within a request, flush after response
import { after } from "next/server";
const a = analytics();          // singleton from src/lib/analytics.ts
a.track("foo", { ... }, { userId });
a.track("bar", { ... }, { userId });
after(() => a.flush());          // runs after response is sent

Auth chain

The server entry resolves credentials in this order:

  1. BQA_ACCESS_TOKEN env var (explicit override)
  2. Vercel OIDC token (production / preview / development on Vercel). Modern Vercel runtimes don't expose this as an env var — fetched per-request via @vercel/functions/oidc's getVercelOidcToken(). Make sure @vercel/functions is in your project's dependencies. Older runtimes that still set VERCEL_OIDC_TOKEN env var also work as a fallback. The fetched JWT is exchanged through Google STS + service-account impersonation.
  3. GOOGLE_APPLICATION_CREDENTIALS_JSON (service-account JSON pasted into env, for non-Vercel deployments)
  4. Application Default Credentials (gcloud auth application-default login for local dev)

Tokens are cached for ~1h to avoid re-exchanging on every insert.

Local dev / smoke test

gcloud auth application-default login

GCP_PROJECT_ID=my-project pnpm smoke
pnpm smoke:query <run_id>

pnpm smoke:flags             # read latency + propagation + missing-key
pnpm smoke:flags-targeting   # allowlist / rollout / cohort / exposure / refresh

Events smoke writes to bq_analytics_smoke_events and bq_analytics_smoke_logs datasets you can drop afterwards (scripts/teardown.sh). Flag smokes write transient keys into Edge Config and clean up after themselves.

Tests

pnpm test                  # 92 unit tests, no network
pnpm test:integration      # real BQ — requires BQ_INTEGRATION=1 and ADC

Tear down

GCP_PROJECT_ID=my-project ./scripts/teardown.sh

# if you set up flags
EC_ID=$(grep '^EDGE_CONFIG=' .env.local | sed -E 's|.*/(ecfg_[^?]+)\?.*|\1|')
vercel edge-config remove "$EC_ID"
vercel env rm EDGE_CONFIG production

Prompts before each destruction. Reversible WIF pool delete, irreversible dataset + Edge Config delete.

License

MIT.