---
title: "Track product events without identifying people"
description: "Declare each product event and its props, send events with consent, and query active users, funnels and retention from the built-in store."
section: "guides"
status: "Building for 0.3"
spec: "analytics"
canonical: "https://mainsdk.dev/docs/guides/track-events"
updated: "2026-10-28"
---

# Track product events without identifying people

> Declare each product event and its props, send events with consent, and query active users, funnels and retention from the built-in store.

Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/track-events · Updated: 2026-10-28

You want to know which features people use and where they drop off, without learning who anyone is. This guide declares two events, tracks them, and reads the numbers back. It takes about 20 minutes.

Built for 0.3 (spec `analytics`). The built-in store keeps events in the tenant's region; PostHog and an OpenTelemetry collector are optional drivers.

## You need

- A MainSDK app with [privacy](https://mainsdk.dev/docs/interfaces/privacy) consent set up for the `analytics` purpose.

## 1. Declare the events

```yaml title="main.yaml"
analytics:
  to: [builtin]
  without-consent: drop
  min-group: 10
  events:
    checkout.started:
      purpose: analytics
      props: { plan: "enum(free, pro)", items: int, coupon: "string?" }
    lesson.finished:
      purpose: analytics
      props: { subject: "enum(math, science, reading)", minutes: int }
```

Only declared events are accepted. A prop that would identify a person, such as `email` or `ip`, fails the check with `MAIN-ANALYTICS-0011`.

## 2. Track the events

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

export async function startCheckout(req: Request, plan: string) {
  await auth.require(req);
  analytics.track("checkout.started", { plan, items: 3 });
}
```

The user and tenant come from the request. You never pass them in.

## 3. Read the numbers

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

const funnel = await analytics.query({
  kind: "funnel", steps: ["checkout.started", "lesson.finished"], within: "7d",
  from: "2026-10-01", to: "2026-10-31",
});
```

Rows for fewer than `min-group` people are hidden, and the result says how many were hidden.

From the terminal, `mainsdk analytics` asks the same questions across every tenant:

```text
npx mainsdk analytics --query funnel --steps checkout.started,lesson.finished --within 7d
```

## Check it worked

- `mainsdk check` passes with no `MAIN-ANALYTICS-0010` or `0011` findings.
- Tracking an event you did not declare throws `MAIN-ANALYTICS-0001` in development.
- Without consent for the purpose, the event is dropped and no row is stored.

## If it fails

- `MAIN-ANALYTICS-0002`: props do not match the declared types. Send values that match.
- `MAIN-ANALYTICS-0004`: a query names an undeclared event or uses a `by` its kind does not take.
- `MAIN-ANALYTICS-0007`: `analytics.query` needs `builtin` in `analytics.to`.

## Prompt for your coding agent

> Track `checkout.started` and `lesson.finished` with MainSDK's `analytics` interface. Declare their props with types, keep identifying fields out, and add a funnel query that a non-technical admin can read in the app.
