Product analytics
analyticsTrack product events and page views with pseudonymised user IDs, and nothing sent before consent where it is required.
analyticsFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
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
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] }import { analytics } from "mainsdk";
analytics.track("checkout.started", { plan: "pro", items: 3 });Drivers
4 drivers. Select one to see its line in main.yaml.
analytics:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-ANALYTICS-0010 | Checkmainsdk check, before deploy | analytics.track or page with an event name that isn't declared, or isn't a string literal |
MAIN-ANALYTICS-0011 | Checkmainsdk check, before deploy | 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 | Checkmainsdk check, before deploy | 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 | Checkmainsdk check, before deploy | A children's audience (users.audience: children) with a per-person driver (posthog) in analytics.to |
MAIN-ANALYTICS-0001 | RuntimeError responses and logs | 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 | RuntimeError responses and logs | 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 | RuntimeError responses and logs | An event was dropped: no consent for its purpose. Logged as a count, never with the person |
MAIN-ANALYTICS-0004 | RuntimeError responses and logs | A query names an undeclared event, prop or metric, or a by the query kind doesn't take (400) |
MAIN-ANALYTICS-0005 | RuntimeError responses and logs | 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 | RuntimeError responses and logs | A driver in analytics.to is unreachable; events were buffered, then dropped with a counter |
MAIN-ANALYTICS-0007 | RuntimeError responses and logs | analytics.query called without builtin in analytics.to (501) |
MAIN-ANALYTICS-0008 | RuntimeError responses and logs | The event's region store isn't configured or reachable; the event was dropped and written nowhere else |
MAIN-ANALYTICS-0009 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| analytics@1 contract | Phase 1 | 0.3 | analytics#contract |
| Track and page with typed props | Phase 1 | 0.3 | analytics#track |
| Consent and per-app keyed ids | Phase 1 | 0.3 | analytics#consent-ids |
| Children aggregate-only | Phase 1 | 0.3 | analytics#children |
| Built-in store in each tenant's region | Phase 1 | 0.3 | analytics#builtin-store |
| Counters | Phase 1 | 0.3 | analytics#metrics |
| Queries: counts, active users, retention and funnels | Phase 1 | 0.3 | analytics#query |
| PostHog and OpenTelemetry drivers | Phase 1 | 0.3 | analytics#drivers |
| Checks for undeclared events and identifying props | Phase 1 | 0.3 | analytics#checks |
| Consent decided on the device | Phase 3 | 1.1 | expo-adapter#on-device-policy |
| Active users by country, region and version | Phase 1 | 0.3 | analytics#query |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| Plan | What it adds |
|---|---|
| Teams | Usage dashboards |
| Fleet | A map across every app, checked against each app's declared markets and residency |
Quick start
analytics:
to: [builtin]
events:
checkout.started:
purpose: analytics
props: { plan: "enum(free, pro)" }"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
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, 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.yamldeclares 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-grouphidden. mainsdk analyticsto ask the same questions from the terminal, and checks for undeclared events and identifying props.
Examples
Track page views in the browser
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
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.
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
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 |
Swapping drivers
analytics:
to: [posthog] # was builtinYour 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 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 to record consent, or connect the consent platform you already run.
Next
Was this page helpful?