Open source · MIT · built for Cloudflare Workers

First-party analytics that lives in your Cloudflare account.

Event and session-recording ingest, a query engine over Workers Analytics Engine and R2, and a Grafana-style dashboard. One npm package. No third-party tracker, no external vendor, no data leaving your account.

Open the live demo

The demo runs the real package (query engine, dashboard and Explore) over synthetic traffic held in memory by this Worker.

server Ingest

One createLake() fetch handler. Events go to Analytics Engine, session-recorder batches to R2 as immutable JSONL, and a 1×1 pixel counts page loads without JavaScript.

  • Visitor cookie, device class, bot and datacenter-ASN filtering
  • Hooks for consent, rate limits, staff exclusion and redaction

server Query

A whitelisted field registry over four tables: events, sessions, batches, actions. Exact predicates compile to Analytics Engine SQL; the rest run in the Worker.

  • Group, bucket, count, uniq, sum, avg, p50, p95
  • Every answer says how it was computed and where caps applied

react Dashboard

A drag-and-resize panel grid with 13 visualizations, a session browser, wallboard mode, a ⌘K command bar and Explore: a schema-driven query builder.

  • Dashboards are plain JSON, saved per browser
  • Pluggable datasources and transforms

How it works

Writes are fire-and-forget at the edge. Reads go through one gated route that speaks a single frame shape, so every panel consumes every store the same way.

Data flow The browser tracker posts events and recordings to createLake in your Worker. Events are written to Analytics Engine and recordings to R2. The dashboard reads both through the same Worker's query route. BrowsercreateTracker() Dashboard@qemuguest/lake/dashboard Your WorkercreateLake().fetch POST /event · /recGET /vGET /query (authorized)GET /sql (authorized) Analytics Engineone row per event R2JSONL per recorder flush ingestframes writeread writeread

Quickstart

Three files. Bindings first, then mount the handler, then drop in the tracker and the dashboard.

1 Bind the stores

// wrangler.jsonc
"analytics_engine_datasets": [
  { "binding": "ANALYTICS", "dataset": "my_events" }
],
"r2_buckets": [
  { "binding": "RECORDINGS", "bucket_name": "my-recordings" }
]
// secrets: CF_ACCOUNT_ID, CF_ANALYTICS_TOKEN, LAKE_KEY

2 Mount the lake

import { createLake, keyAuth } from '@qemuguest/lake';

export default {
  async fetch(req, env) {
    const lake = createLake({
      authorize: keyAuth(env.LAKE_KEY),
      analytics: env.ANALYTICS,
      recordings: env.RECORDINGS,
      ae: {
        accountId: env.CF_ACCOUNT_ID,
        apiToken: env.CF_ANALYTICS_TOKEN,
        dataset: 'my_events',
      },
    });
    return (await lake.fetch(req)) ?? app(req, env);
  },
};

3 Track and look

import { createTracker } from '@qemuguest/lake/client';

const lake = createTracker({ endpoint: '/lake' });
lake.track('page_view');
lake.startHeartbeat();
lake.startRecorder();
import {
  LakeDashboard, sqlDashboard,
  registerBuiltinDataSources, registerBuiltinTransforms,
} from '@qemuguest/lake/dashboard';

registerBuiltinDataSources({
  baseUrl: '/lake',
  headers: { Authorization: `Bearer ${key}` },
});
registerBuiltinTransforms();

<LakeDashboard dashboard={sqlDashboard} />

Ask it questions in plain lines

Panels store a small query language: the table on the first line, then key=value. Explore builds it for you from the live schema, including any props.* key your app has started sending.

Every result carries its plan. When a predicate can't be pushed into SQL exactly, the Worker re-checks it and the response says so instead of silently approximating.

Try it in Explore
events
# visitors by country, last 30 days, no bots
where=device:neq:bot
where=type:eq:page_view
group=country
agg=uniq(identity):visitors,count:views
sort=-visitors
limit=10
from=now-30d

Yours, end to end

Data is written to stores in your own Cloudflare account and read with your own token. Nothing is sent to a vendor.

Privacy hooks built in

Consent gate, untracked paths, staff exclusion and a redaction pass on every URL and prop. IP is flagged as PII in the schema.

Honest numbers

Sample-weighted aggregates, explicit caps, and a truncated flag with reasons whenever a result is partial.

Runs without Cloudflare too

MemoryAnalytics and MemoryBucket stand in for both stores, so tests and local dev need no account.

See it with data in it.

Drag panels, open a session, switch to wallboard mode. Nothing you do in the demo is recorded.

Open the live demo