AgentStack
MCP verified MIT Self-run

Bq Analytics

mcp-johnkueh-bq-analytics · by johnkueh

Analytics your AI agent can actually query. Tiny SDK that pipes events to BigQuery — ~$0/mo at indie scale, no dashboard, no vendor lock-in.

No reviews yet
0 installs
4 views
0.0% view→install

Install

$ agentstack add mcp-johnkueh-bq-analytics

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No 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.

Are you the author of Bq Analytics? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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

Versions

  • v0.1.0 Imported from the upstream source.