---
title: "Product analytics"
description: "Track product events and page views with pseudonymised user IDs, and nothing sent before consent where it is required."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "analytics"
canonical: "https://mainsdk.dev/docs/interfaces/analytics"
updated: "2026-10-28"
---

# Product analytics

> Track product events and page views with pseudonymised user IDs, and nothing sent before consent where it is required.

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/analytics · Updated: 2026-10-28

`analytics` records declared product events and metrics under pseudonymised IDs, only with consent where it is required. Reach for it to learn which features people use and where they drop off.

Built for 0.3 (spec `analytics`). Declare each event and its props in `main.yaml`. Only declared events are accepted, and a person's events are recorded only with consent for their purpose.

## At a glance

- Status: Building for 0.3 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/analytics.md
- Category: Observability and analytics

Calls:

- `analytics.track(event, props?)`
- `analytics.page(path, props?)`
- `analytics.counter(name).add(n?, attrs?)`
- `analytics.gauge(name).set(value, attrs?)`
- `analytics.histogram(name).record(value, attrs?)`
- `analytics.query(q)`

In main.yaml:

```yaml
analytics:
  to: [builtin]
  without-consent: drop
  buffer: 10000
  posthog: { host: eu.i.posthog.com, key: "secret:POSTHOG_KEY", regions: [eu] }
  min-group: 10
  events:
    checkout.started:
      purpose: analytics
      props: { plan: "enum(free, pro)", items: int }
  metrics:
    checkout.duration: { type: histogram, unit: ms, attrs: [plan] }
```

Guarantees, whatever the driver:

- Events are recorded only with consent for their purpose
- Only declared events and typed props are accepted
- Children's audiences are aggregate-only
- Small groups are hidden in query results


## Drivers

Pick one in main.yaml with `analytics.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in (on db) | `builtin` | yes |  |
| PostHog | `posthog` |  |  |
| Any OpenTelemetry backend | `otel` |  |  |
| Plausible | `plausible` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-ANALYTICS-0010` | check | `analytics.track` or `page` with an event name that isn't declared, or isn't a string literal |
| `MAIN-ANALYTICS-0011` | check | A prop or metric attribute that would identify a person: a name such as `email`, `phone`, `name`, `ip`, `userId`, `deviceId`, `idfa`, `gaid` or `fingerprint`, or a field with a data label |
| `MAIN-ANALYTICS-0012` | check | A driver in `analytics.to` doesn't serve a region the app stores data in, or declares cross-app identifiers or third-party cookies |
| `MAIN-ANALYTICS-0013` | check | A children's audience (`users.audience: children`) with a per-person driver (`posthog`) in `analytics.to` |
| `MAIN-ANALYTICS-0001` | runtime | A product event that `analytics.events` doesn't declare (throws in dev and test; dropped and logged once per name in production) |
| `MAIN-ANALYTICS-0002` | runtime | An event's props fail its schema (throws 422 with issue paths in dev and test; dropped and logged once per name in production) |
| `MAIN-ANALYTICS-0003` | runtime | An event was dropped: no consent for its purpose. Logged as a count, never with the person |
| `MAIN-ANALYTICS-0004` | runtime | A query names an undeclared event, prop or metric, or a `by` the query kind doesn't take (400) |
| `MAIN-ANALYTICS-0005` | runtime | A metric attribute that isn't declared was dropped, or a metric passed 1,000 attribute combinations and the rest were folded into `_other` |
| `MAIN-ANALYTICS-0006` | runtime | A driver in `analytics.to` is unreachable; events were buffered, then dropped with a counter |
| `MAIN-ANALYTICS-0007` | runtime | `analytics.query` called without `builtin` in `analytics.to` (501) |
| `MAIN-ANALYTICS-0008` | runtime | The event's region store isn't configured or reachable; the event was dropped and written nowhere else |
| `MAIN-ANALYTICS-0009` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| analytics@1 contract | 1 | 0.3 | `analytics#contract` |
| Track and page with typed props | 1 | 0.3 | `analytics#track` |
| Consent and per-app keyed ids | 1 | 0.3 | `analytics#consent-ids` |
| Children aggregate-only | 1 | 0.3 | `analytics#children` |
| Built-in store in each tenant's region | 1 | 0.3 | `analytics#builtin-store` |
| Counters | 1 | 0.3 | `analytics#metrics` |
| Queries: counts, active users, retention and funnels | 1 | 0.3 | `analytics#query` |
| PostHog and OpenTelemetry drivers | 1 | 0.3 | `analytics#drivers` |
| Checks for undeclared events and identifying props | 1 | 0.3 | `analytics#checks` |
| Consent decided on the device | 3 | 1.1 | `expo-adapter#on-device-policy` |
| Active users by country, region and version | 1 | 0.3 | `analytics#query` |

## MainSDK Cloud

Everything on this page is free in your app.
Cloud adds:

| Plan | What it adds |
| --- | --- |
| teams | Usage dashboards |
| fleet | A map across every app, checked against each app's declared markets and residency |


## Quick start

```yaml title="main.yaml"
analytics:
  to: [builtin]
  events:
    checkout.started:
      purpose: analytics
      props: { plan: "enum(free, pro)" }
```

```ts title="app/checkout/actions.ts"
"use server";
import { analytics, auth } from "mainsdk";

export async function startCheckout(req: Request, plan: string) {
  const session = await auth.require(req);
  analytics.track("checkout.started", { plan });   // the user and tenant come from the request
}
```

## Configuration

```yaml title="main.yaml"
analytics:
  to: [builtin]                  # builtin (on db) | posthog | otel; queries need builtin
  without-consent: drop          # drop (default) | aggregate
  min-group: 10                  # results for fewer people are hidden; may only be raised
  events:
    checkout.started:
      purpose: analytics
      props: { plan: "enum(free, pro)", items: int, coupon: "string?" }
  metrics:
    checkout.duration: { type: histogram, unit: ms, attrs: [plan] }
  posthog: { host: eu.i.posthog.com, key: secret:POSTHOG_KEY, regions: [eu] }
```

| Key | What it does |
| --- | --- |
| `to` | Where events go. `builtin` stores them on `db` in the tenant's region |
| `without-consent` | `drop` records nothing. `aggregate` counts the event with no id and no props |
| `events` | Every event the app may track, with its consent `purpose` and typed `props` |
| `metrics` | Counters, gauges and histograms with declared attributes |

Provider keys go in as `secret:NAME` references. Consent comes from [`privacy`](https://mainsdk.dev/docs/interfaces/privacy), so there is no second banner. An undeclared event fails with `MAIN-ANALYTICS-0001`, and props that fail their type with `MAIN-ANALYTICS-0002`.

## What you get

- Events recorded only with consent for their purpose, and only if `main.yaml` declares them with typed props.
- A built-in store in each tenant's region, with PostHog and any OpenTelemetry backend as optional drivers.
- Children's audiences counted as aggregates, with no id and no props beyond enums, booleans and integers.
- Queries for counts, active people, feature use, retention and funnels, with groups under `min-group` hidden.
- `mainsdk analytics` to ask the same questions from the terminal, and checks for undeclared events and identifying props.

## Examples

### Track page views in the browser

```ts title="web/page-views.ts"
import { analytics } from "mainsdk";

// call it from your router's route-change hook, for example a React effect keyed on the pathname
export function onRouteChange(pathname: string) {
  analytics.page(pathname);
}
```

### Track a server-side event from a job

```ts title="jobs/subscriptions.ts"
import { analytics } from "mainsdk";

export function onSubscriptionRenewed(plan: string) {
  analytics.track("subscription.renewed", { plan });
}
```

### Metrics and queries

Counters, gauges and histograms go out over OpenTelemetry. Active users by country, region or app version (the old `stats`) is a query too, see the example below. `analytics.query(q)` reads the built-in store for the current tenant: counts, active users, feature use, retention and funnels. Rows for fewer than `min-group` people are hidden.

```ts title="lib/metrics.ts"
import { analytics } from "mainsdk";

analytics.counter("exports.made").add(1, { format: "csv" });
analytics.gauge("queue.depth").set(12, { queue: "emails" });
analytics.histogram("checkout.duration").record(840, { plan: "pro" });

const result = await analytics.query({
  kind: "count", event: "checkout.started", by: ["day"], from: "2026-10-01", to: "2026-10-31",
});
```

### Active users by country

```ts title="scripts/usage.ts"
import { analytics } from "mainsdk";

const byCountry = await analytics.query({
  kind: "active", window: "month", by: ["country"], from: "2026-10-01", to: "2026-10-31",
});
```

`by` takes `country`, `region`, `version`, `plan`, `day`, `week` or `month`.

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-ANALYTICS-0001` | An event that `analytics.events` doesn't declare | Declare it |
| `MAIN-ANALYTICS-0002` | An event's props fail their schema | Fix the props |
| `MAIN-ANALYTICS-0003` | Dropped for no consent (logged as a count) | Collect consent for the purpose |
| `MAIN-ANALYTICS-0004` | A query names an undeclared event, prop or metric | Declare it, or fix the query |
| `MAIN-ANALYTICS-0007` | `analytics.query` without `builtin` | Add `builtin` to `analytics.to` |
| `MAIN-ANALYTICS-0008` | The event's region store isn't configured | Configure the region store; nothing is written in another region |
| `MAIN-ANALYTICS-0009` | The `analytics` section of `main.yaml` is invalid | Fix the key the message names |
| `MAIN-ANALYTICS-0010` to `0013` | Check findings: undeclared events, identifying props, drivers outside your regions, per-person drivers for children | See [check codes](https://mainsdk.dev/docs/reference/check-codes) |

## Swapping drivers

```yaml title="main.yaml"
analytics:
  to: [posthog]   # was builtin
```

Your `track` and `page` calls stay the same. `analytics.query()` needs `builtin`, or it fails with `MAIN-ANALYTICS-0007`. Past events stay in the old tool.

## Testing

`createTestApp({ main: "./main.yaml" })` from the [Testing kit](https://mainsdk.dev/docs/interfaces/testing) runs `analytics` on an in-memory fake, so tests send nothing to your provider. Helpers to set consent on the fake and assert on tracked events arrive with the spec.

## Security and privacy

- Nothing is sent without consent where it is required.
- Only pseudonymised user IDs leave your app, so erasing a user there is a mapping delete.
- Keep personal data out of event properties.
- On mobile, consent is decided on the device from phase 3 (spec `expo-adapter`).

## FAQ

**Can I send events to MainSDK Cloud?** Yes, `mainsdk` is one of the drivers. Nothing goes to MainSDK Cloud without login and opt-in.

**Do I need my own consent banner?** No. Use [`privacy`](https://mainsdk.dev/docs/interfaces/privacy) to record consent, or connect the consent platform you already run.

## Next

- [Privacy and data rights](https://mainsdk.dev/docs/interfaces/privacy)
- [Feature flags](https://mainsdk.dev/docs/interfaces/flags)
