Tracking plan (analytics.yaml)
The tracking plan is a file in your repository: analytics.yaml. It lists every event your code sends, with a description and the props. The file is the source of truth. twelvebit keeps a copy, so the dashboard and the MCP server know your events, and so it can show events that are planned but never sent.
Full example#
version: 1
project: example.com
integrations: [stripe]
events:
signup:
description: A visitor creates an account
tags: [activation]
props:
method:
type: string
enum: [email, google, github]
description: How the account was created
project_created:
description: A user creates a project
tags: [activation]
props:
template: { type: string, enum: [blank, blog, store] }
from_onboarding: { type: boolean, required: false }
checkout_started:
description: A user opens the checkout
tags: [revenue]
props:
plan: { type: string, enum: [starter, pro] }
seats: { type: number, required: false }
subscription_started:
description: The first payment of a subscription succeeds (sent by the server)
tags: [revenue]
revenue: true
props:
plan: { type: string, enum: [starter, pro] }
dashboards:
funnels:
- name: Signup to paid
steps: ["page:/pricing", signup, checkout_started, subscription_started]Schema#
The schema is strict. An unknown key is an error, so a typo does not silently do nothing.
| Key | Type | Rules |
|---|---|---|
version | number | Required. Must be 1. |
project | string | Your domain, for example example.com. |
integrations | list | Any of stripe, polar, lemonsqueezy, paddle, dodo, shopify, other. |
events | map | Required. Up to 200 events. Key = the event name. |
events.<name>.description | string | Required. Up to 500 characters. |
events.<name>.tags | list of strings | Up to 10. Groups events in the dashboard. |
events.<name>.revenue | boolean | true for events that carry money. Send them from the server. |
events.<name>.props | map | Up to 30 props. Key = the prop name. |
props.<name>.type | string | Required. string, number or boolean. |
props.<name>.enum | list of strings | Allowed values. Only for type: string. |
props.<name>.required | boolean | Props are optional by default. true makes the prop required in the generated types. |
props.<name>.description | string | Up to 500 characters. |
dashboards.funnels | list | Up to 20 funnels. |
dashboards.funnels[].name | string | Required. |
dashboards.funnels[].steps | list | 2 to 8 steps. A step is an event in this plan (signup or goal:signup), payment (twelvebit adds this goal for each payment), or a page (page:/pricing; * is a wildcard). |
Names of events and props: lowercase snake_case, start with a letter (^[a-z][a-z0-9_]*$), at most 64 characters. Two names that give the same camelCase name in generated code (a_1 and a1, both a1) are rejected. The names pageview, identify, outbound, ping and payment are reserved by the script.
Typed helpers#
Generate them from the plan:
npx -y https://twelvebit.app/cli/twelvebit-cli.tgz codegenThis writes analytics.gen.ts, one camelCase function per event on a track object, with the props as types, and then pushes the plan (use --no-push to skip that). Commit the file. The output looks like this (details can change between CLI versions):
// analytics.gen.ts: generated by `twelvebit codegen` from analytics.yaml. Do not edit by hand.
type Value = string | number | boolean;
type Twelvebit = (name: string, props?: Record<string, Value>) => void;
// No-op on the server and when the script is blocked, so tracking never breaks the app.
function send(name: string, props?: object) {
if (typeof window === "undefined") return;
try {
(window as unknown as { twelvebit?: Twelvebit }).twelvebit?.(name, props as Record<string, Value> | undefined);
} catch {}
}
export type SignupProps = {
/** How the account was created */
method?: "email" | "google" | "github";
};
export const track = {
/** A visitor creates an account */
signup: (props?: SignupProps) => send("signup", props),
// … one function per event: projectCreated, checkoutStarted, subscriptionStarted
};
export function identify(user: { user_id: string; email?: string; name?: string; [trait: string]: Value | undefined }) {
send("identify", user);
}If you do not use codegen, write a small helper by hand:
// lib/track.ts
type Events = {
signup: { method: "email" | "google" | "github" };
project_created: { template: "blank" | "blog" | "store"; from_onboarding?: boolean };
checkout_started: { plan: "starter" | "pro"; seats?: number };
};
declare global {
interface Window {
twelvebit?: (name: string, props?: Record<string, string | number | boolean | undefined>) => void;
}
}
export function track<E extends keyof Events>(name: E, props: Events[E]) {
if (typeof window !== "undefined") window.twelvebit?.(name, props);
}
// track("signup", { method: "google" });Push the plan#
The CLI:
npx -y https://twelvebit.app/cli/twelvebit-cli.tgz pushOr plain HTTP (YAML or JSON):
curl -sS -X PUT https://twelvebit.app/api/v1/tracking-plan -H "Authorization: Bearer $TWELVEBIT_API_KEY" \
-H "Content-Type: text/yaml" --data-binary @analytics.yamlThe response says what changed compared with the plan that twelvebit had: the events that were added, removed and changed, and whether the funnels changed. A plan that does not match the schema is rejected with status 422 (code invalid_plan) and a list of errors, each with a path (for example events.signup.props.plan.enum) and a message.
Add ?dry_run=1 to validate the plan and see the diff without saving it.
Read the stored plan with GET /api/v1/tracking-plan or npx -y https://twelvebit.app/cli/twelvebit-cli.tgz pull. It comes back as YAML in a fixed key order, without the comments of your file, so keep the file in git as the source of truth.
Drift checks#
Drift is when the code and the plan disagree. There are two checks.
In the code, without network access:
npx -y https://twelvebit.app/cli/twelvebit-cli.tgz check --strictIt scans your source files for twelvebit("…") calls, track.…() calls of the generated helpers and data-tb-goal attributes, and compares them with analytics.yaml. It reports events that the code sends but the plan does not declare, events that the plan declares but the code never sends, and an analytics.gen.ts that is older than the plan. With --strict, it exits with code 1 when there is drift.
In production, the install check lists each planned event and whether twelvebit has received it:
curl -sS https://twelvebit.app/api/v1/install-check -H "Authorization: Bearer $TWELVEBIT_API_KEY"Run the check in CI#
# .github/workflows/analytics.yml
name: analytics
on: [pull_request]
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npx -y https://twelvebit.app/cli/twelvebit-cli.tgz check --strictTo push the plan on merge to main, add a job that runs npx -y https://twelvebit.app/cli/twelvebit-cli.tgz push with TWELVEBIT_API_KEY from the repository secrets.