MainSDK
Search⌘K
Interfaces

Observability and analytics

Product analytics

analytics

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

Building for 0.3ships as a beta APIPhase 1 · Core, public betaSpec analyticsFree in your app, Apache-2.0

Status 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.
Stability levels
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

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] }

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)

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

4 drivers. Select one to see its line in main.yaml.

main.yamlBuilt in (on db) (default)
analytics:
  driver: builtin

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-ANALYTICS-0010Checkmainsdk check, before deployanalytics.track or page with an event name that isn't declared, or isn't a string literal
MAIN-ANALYTICS-0011Checkmainsdk check, before deployA 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-0012Checkmainsdk check, before deployA 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-0013Checkmainsdk check, before deployA children's audience (users.audience: children) with a per-person driver (posthog) in analytics.to
MAIN-ANALYTICS-0001RuntimeError responses and logsA product event that analytics.events doesn't declare (throws in dev and test; dropped and logged once per name in production)
MAIN-ANALYTICS-0002RuntimeError responses and logsAn 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-0003RuntimeError responses and logsAn event was dropped: no consent for its purpose. Logged as a count, never with the person
MAIN-ANALYTICS-0004RuntimeError responses and logsA query names an undeclared event, prop or metric, or a by the query kind doesn't take (400)
MAIN-ANALYTICS-0005RuntimeError responses and logsA 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-0006RuntimeError responses and logsA driver in analytics.to is unreachable; events were buffered, then dropped with a counter
MAIN-ANALYTICS-0007RuntimeError responses and logsanalytics.query called without builtin in analytics.to (501)
MAIN-ANALYTICS-0008RuntimeError responses and logsThe event's region store isn't configured or reachable; the event was dropped and written nowhere else
MAIN-ANALYTICS-0009RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
analytics@1 contractPhase 10.3analytics#contract
Track and page with typed propsPhase 10.3analytics#track
Consent and per-app keyed idsPhase 10.3analytics#consent-ids
Children aggregate-onlyPhase 10.3analytics#children
Built-in store in each tenant's regionPhase 10.3analytics#builtin-store
CountersPhase 10.3analytics#metrics
Queries: counts, active users, retention and funnelsPhase 10.3analytics#query
PostHog and OpenTelemetry driversPhase 10.3analytics#drivers
Checks for undeclared events and identifying propsPhase 10.3analytics#checks
Consent decided on the devicePhase 31.1expo-adapter#on-device-policy
Active users by country, region and versionPhase 10.3analytics#query

MainSDK Cloud

Everything above is free in your app. Cloud adds these on top, by plan:

PlanWhat it adds
TeamsUsage dashboards
FleetA map across every app, checked against each app's declared markets and residency

Quick start

main.yaml
analytics:
  to: [builtin]
  events:
    checkout.started:
      purpose: analytics
      props: { plan: "enum(free, pro)" }
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

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] }
KeyWhat it does
toWhere events go. builtin stores them on db in the tenant's region
eventsEvery event the app may track, with its consent purpose and typed props
metricsCounters, 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.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

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

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.

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

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

CodeWhenFix
MAIN-ANALYTICS-0001An event that analytics.events doesn't declareDeclare it
MAIN-ANALYTICS-0002An event's props fail their schemaFix the props
MAIN-ANALYTICS-0003Dropped for no consent (logged as a count)Collect consent for the purpose
MAIN-ANALYTICS-0004A query names an undeclared event, prop or metricDeclare it, or fix the query
MAIN-ANALYTICS-0007analytics.query without builtinAdd builtin to analytics.to
MAIN-ANALYTICS-0008The event's region store isn't configuredConfigure the region store; nothing is written in another region
MAIN-ANALYTICS-0009The analytics section of main.yaml is invalidFix the key the message names
MAIN-ANALYTICS-0010 to 0013Check findings: undeclared events, identifying props, drivers outside your regions, per-person drivers for childrenSee check codes

Swapping drivers

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

↑↓ moveEnter openEsc close

Not decided yet