How one event moves through Rawbbit

Rawbbit is an open-source game analytics pipeline. This article follows a single event from the moment a player triggers it to the moment someone on the team gets an answer, with the command to try each step yourself.

Rawbbit architecture: mobile, backend, and browser game producers send events over HTTP to a collector-api, NATS JetStream message broker, and raw-writer running as Docker containers on a VM. Events land as partitioned Parquet in object storage (S3-compatible SeaweedFS). A dbt job loads Parquet into ClickHouse (analytics.events OBT); Metabase provides BI dashboards. An MCP server exposes the analytics layer to AI agents (Opencode, OpenClaw, Codex, Claude).
End-to-end Rawbbit pipeline: HTTP ingestion to raw Parquet, ClickHouse serving layers, modeled analytics tables, dashboards, and an MCP-powered AI agents layer.

The event leaves your game

A player fails level 12, buys a health potion, and finishes the level. Your game emits an event for each of those moments and sends them in batches over HTTP.

Rawbbit does not ask you to declare a schema first. Whatever your game emits is what lands in the raw layer, in full detail.

Endpoint is already available to get event data from your client or backend. Native Unity, Unreal, Godot SDKs are coming soon, but studios can send events from their backend or client over plain HTTP for now.

The collector accepts it or rejects it

The batch arrives at the collector API, authenticated with a per-project API key. The collector validates it, enriches the accepted events, and publishes them into the stream. Nothing is dropped silently: a batch is either accepted or rejected with a reason.

What you configure here is the collector's limits, its API keys, CORS settings, and optional GeoIP attribution requirements. The service ships as a published image with no secrets baked in.

SEND AN EVENT

curl -sS -X POST https://collector-api.rawbbit.net/v1/events:batch \
  -H 'Content-Type: application/json' \
  -H 'X-API-Key: dev-api-key' \
  -d '{"events":[{"event_id":"00000000-0000-0000-0000-000000000001","app_id":"com.example.game","environment":"prod","event_name":"level_failed","event_timestamp":"2026-03-20T18:42:15.123Z","user":{"user_pseudo_id":"player_anon_001","session_id":"session_001"},"event_params":{"level_id":12}},{"event_id":"00000000-0000-0000-0000-000000000002","app_id":"com.example.game","environment":"prod","event_name":"purchase","event_timestamp":"2026-03-20T18:42:16.123Z","user":{"user_pseudo_id":"player_anon_001","session_id":"session_001"},"event_params":{"value":4.99,"currency":"EUR"}}]}'

The buffer that absorbs the spike

Events go into NATS JetStream before anything writes to storage. This separates request handling from storage writes, which is what keeps a launch spike from becoming an incident: if the writer slows down or restarts, the stream holds the backlog instead of the collector rejecting traffic.

Delivery is at-least-once. The raw writer acknowledges a message only after the write succeeds, so an event that entered the stream is not lost because a downstream component was briefly unavailable. Stream settings and the writer's batching and ACK behavior are both configurable.

INSPECT THE STREAM

nats stream info EVENTS
nats consumer info EVENTS raw-writer
# representative fields; values vary by deployment
Stream Name: EVENTS
Messages: <messages held>
Consumer: raw-writer
Ack Pending: <pending messages>

Raw Parquet lands in storage you own

The raw writer consumes the stream and lands partitioned Parquet files in S3-compatible object storage, open-source SeaweedFS by default, with GCS also supported. You configure the bucket, the prefix, and the credentials; nothing else about this layer is opinionated.

This raw layer is the system of record. It is also the boundary between the two machines in a production deployment, which is why everything downstream can be rebuilt without touching ingestion.

WHAT LANDS IN STORAGE

raw/
  app_id=com.example.game/
    event_date=2026-03-20/hour=18/
      part-20260320T184215-abc123.parquet

dbt loads it into ClickHouse,
on a schedule that tolerates late data

A dbt Core project loads bounded windows of raw Parquet into the analytics.events table in ClickHouse. It runs in its own dbt-runner container with Supercronic in the foreground: an hourly build with a short configurable lookback, and a daily reconciliation over a longer range that picks up files which arrived late.

The model uses dbt-clickhouse's delete_insert incremental strategy keyed on (app_id, event_id). For each key in the current window it selects one winner, deletes matching keys in the target, and inserts. That is what makes the overlapping windows safe: replaying a window cannot duplicate an event, so a delayed file simply lands on the next pass. Supercronic does not replay missed ticks and does not need to, because the overlapping idempotent windows are the recovery path.

Every run is a dbt build, so data tests run alongside the model. event_id, app_id and event_time must be non-null, and (app_id, event_id) must be globally unique in analytics.events. The tests inspect the whole target table, not only the window just loaded, so a problem is caught wherever it came from. Rows with an empty event ID or an unparseable timestamp are rejected rather than written.

Backfills are a command, not a ticket. Pass an hour-aligned UTC range and the job waits for the shared pipeline lock, then processes the range in configurable chunks.

Ingestion ownership is explicit. RAWBBIT_RAW_LOAD_MODE selects whether dbt or the legacy shell loader owns analytics.events, and the two share a lock file so they can never write at the same time. Studios already running the older loader have a migration path rather than a rewrite.

Today this project contains the ingestion model only. Staging, intermediate and mart models go in the same container once their grains and consumers are defined, which is the point of putting the loader in dbt rather than in a scheduler nobody wants to own.

The dbt container receives only its own dedicated ClickHouse credentials. S3 credentials stay inside ClickHouse's rawbbit_raw_s3 named collection, so they never appear in compiled SQL or in dbt artifacts. The dbt user holds the narrow table privileges the incremental strategy and tests require, and nothing more: not the admin account, not the MCP account, not Metabase's.

BACKFILL ANY RANGE

docker compose exec -T \
  -e RAWBBIT_DBT_MIRROR_PID1=1 \
  dbt-runner \
  /app/bin/dbt-job backfill \
  2026-07-01T00:00:00Z \
  2026-07-05T00:00:00Z

Your team asks the question

Three ways in, all reading the same table. Metabase covers the dashboards a studio checks weekly. Analysts connect directly and write SQL against ClickHouse with no export limits and no sampling.

The third is the Rawbbit MCP server, which exposes a read-only analytical surface over your events table. Codex, OpenCode, OpenClaw, Claude, or any other MCP client connects to it and answers questions in plain language by writing and running the SQL for you. Someone can ask what D7 retention looks like for players who finished the tutorial, split by platform, and get an answer without hand-writing the query.

Container logs are reachable over MCP as well, so the same interface that answers analytics questions can also show you why events stopped arriving. The difference from a hosted tool is not that it has an API: it is that the data, the database, and the logs are all yours, so nothing you can ask is limited by what a vendor decided to expose.

POINT AN AGENT AT IT

{
  "mcp": {
    "rawbbit_clickhouse": {
      "type": "remote",
      "url": "https://mcp.rawbbit.net/mcp",
      "enabled": true,
      "headers": { "Authorization": "Bearer <MCP token>" }
    }
  }
}

What you actually operate

Two virtual machines are enough to run this in production.

VM one handles ingestion and raw storage: NATS JetStream, the collector API, the raw writer, and SeaweedFS. VM two handles analytics and access: ClickHouse, the dbt runner, the Rawbbit MCP server, Metabase, and the Postgres instance Metabase uses for its own state. Both VMs expose optional Dozzle log access, in the browser and over MCP.

The boundary between them is the raw Parquet layer. That is a security property as much as an architectural one: an agent working on the analytics side has no path into the ingestion runtime.

Everything runs as Docker containers from public published images, so you can stand the whole stack up locally with Docker Compose before deciding anything.

Want us to run it for you?

Self-host it free