Install
$ agentstack add mcp-johnkueh-bq-analytics ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
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 queryis 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 / feedbackshape, same BigQuery schema. - No queue infra to run. Browser and RN persist failed batches locally and retry on next load. Server-side
track()durability matches PostHog / Segment / Amplitude via the same flush patterns — 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
Prefer to set it up manually?
pnpm add bq-analytics
# One-shot per project: BQ datasets + tables, Vercel OIDC, IAM bindings
TEAM_SLUG=acme PROJECT_NAME=my-app \
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";
// 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](claude-skills/query) 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, 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(); the way to get server log lines into BQ | 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
Next.js on Vercel
// 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/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.
To get server logs into BigQuery, use bq-analytics/logger — a console-shaped wrapper that emits each log line directly to logs.raw via the same transport that handles events.raw. Direct emission keeps logging off the per-invocation HTTP path:
// 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.
Express / Hono / Fastify / Koa / raw Node
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 }).
Browser
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 });
Expo / React Native
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.
Node CLI / scripts
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.
Non-Node (Python, Go, Ruby)
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](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 │
│ │
server logger ─ direct insertAll ─────▶│ logs.raw │
│ │
└──────────────────────────┘
▲
│ bq query (CLI / Claude)
Two pipelines: events.\ (explicit product events from any client, JSON property column → never migrate) and logs.\ (server log lines emitted explicitly via logger.* / analytics.log() — 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 Observability log overage | ~$0.13 (1.25 GB × $0.50/GiB after 1 GB free) | | Total | ~$0.39 / 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.
Schema
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.
Querying — example BQ queries
# 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'
Product feedback — bug reports / requests joinable to events
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 DA
…
## Source & license
This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [johnkueh](https://github.com/johnkueh)
- **Source:** [johnkueh/bq-analytics](https://github.com/johnkueh/bq-analytics)
- **License:** MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.