---
title: "Tracking & Analytics"
description: "Server-side analytics with pluggable providers — PostHog, Mixpanel, Amplitude, or custom webhook"
---

# Analytics Tracking

One function, multiple destinations. Call `track()` from any server-side code — actions, plugins, server routes — and the event fans out to every registered analytics provider. No SDK dependencies, no client-side scripts, no blocking. The same `track()` is also available in [browser/app code](#client) and routes to the same providers.

This is _product_ analytics — your app's events flowing to PostHog/Mixpanel/Amplitude. For _agent quality_ metrics (traces, cost, evals, feedback) stored in your own database, see [Observability](/docs/observability).

```ts
import { track } from "@agent-native/core/tracking";

track(
  "order.completed",
  { total: 49.99, items: 3 },
  { userId: "user@example.com" },
);
```

<Diagram id="doc-block-w1opwu" title="One track() call, every provider" summary="Server and client callers hit the same registry, which fans every event out to all active providers in parallel.">

```html
<div class="trk">
  <div class="diagram-col">
    <div class="diagram-node">
      Server code<br /><small class="diagram-muted"
        >actions &middot; plugins &middot; routes</small
      >
    </div>
    <div class="diagram-node">
      Browser code<br /><small class="diagram-muted"
        >POST /_agent-native/track</small
      >
    </div>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-panel center">
    <span class="diagram-pill accent">Provider registry</span
    ><small class="diagram-muted">fan-out, fire-and-forget</small>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-col">
    <div class="diagram-box">PostHog</div>
    <div class="diagram-box">Mixpanel</div>
    <div class="diagram-box">Amplitude</div>
    <div class="diagram-box">Webhook</div>
  </div>
</div>
```

```css
.trk {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}
.trk .diagram-col {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.trk .diagram-arrow {
  font-size: 22px;
  line-height: 1;
}
.trk .center {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
}
```

</Diagram>

## Built-in providers {#built-in}

Set an env var and the provider auto-registers at server startup. No code changes required.

| Provider  | Env vars                                                                                        |
| --------- | ----------------------------------------------------------------------------------------------- |
| PostHog   | `POSTHOG_API_KEY` (required), `POSTHOG_HOST` (optional, defaults to `https://us.i.posthog.com`) |
| Mixpanel  | `MIXPANEL_TOKEN`                                                                                |
| Amplitude | `AMPLITUDE_API_KEY`                                                                             |
| Webhook   | `TRACKING_WEBHOOK_URL` (required), `TRACKING_WEBHOOK_AUTH` (optional `Authorization` header)    |

Multiple providers can be active simultaneously. Every event goes to all of them.

## PostHog error tracking {#posthog-error-tracking}

With `POSTHOG_API_KEY` set, PostHog also receives server exceptions — no Sentry
DSN required. Server errors are captured automatically from the framework's
route error hook and attributed to the signed-in user, with production-tuned
noise filtering (expected 4xx responses, permission rejections, and serverless
socket churn are dropped rather than reported).

| Env var                  | Purpose                                                             |
| ------------------------ | ------------------------------------------------------------------- |
| `POSTHOG_ERROR_TRACKING` | Set to `false` to keep analytics but send exceptions somewhere else |
| `POSTHOG_PUBLIC_KEY`     | Enables browser exception capture (also read as `VITE_POSTHOG_KEY`) |
| `POSTHOG_PUBLIC_HOST`    | Browser ingest host (defaults to `POSTHOG_HOST`)                    |

`POSTHOG_PUBLIC_KEY` is separate from `POSTHOG_API_KEY` on purpose: the public
key is inlined into the HTML served to every visitor, so the server key is never
used as a fallback for it.

<Callout tone="info">
  PostHog does not symbolicate stack traces without uploaded source maps, so
  **minified browser stacks stay minified**. Server-side stacks are unaffected.
  If you need symbolicated browser traces today, keep Sentry configured
  alongside PostHog — both receive every captured error.
</Callout>

## API {#api}

### `track(name, properties?, source?)` {#track}

Fire an analytics event. Fans out to all registered providers.

```ts
import { track } from "@agent-native/core/tracking";

track(
  "meal.logged",
  { mealName: "Salad", calories: 350 },
  { userId: "user@example.com" },
);
```

`source` is either an action's `ctx`, passed straight through, or a
`{ userId, anonymousId, sessionId }` meta object for callers that have no `ctx`.

The browser session is not something the caller has to supply: it comes from the
ambient request context (`RequestContext.browserSessionId`, set from the
`X-Agent-Native-Session-Id` header the action client, chat, and client `track()`
all send), so the same visit correlates whether the UI or the agent triggered the
event. Each provider maps it to its own session field — `$session_id` for
PostHog, which joins the event to session replay; a `session_id` property for
Mixpanel and Amplitude; a top-level `sessionId` for webhooks and Agent-Native
Analytics. It is absent for callers with no browser (cron, CLI, MCP, A2A), and a
`sessionId` in the meta object overrides it.

### `identify(userId, traits?)` {#identify}

Identify a user with traits. Forwarded to providers that support it (PostHog, Mixpanel, Amplitude, webhook).

```ts
import { identify } from "@agent-native/core/tracking";

identify("user@example.com", { plan: "pro", company: "Builder.io" });
```

Need a custom backend, the provider-registry API, or the batching/singleton internals? See [Advanced: custom providers & internals](#advanced) at the end.

## Using track() in templates {#templates}

Call `track()` from action handlers to record user or agent activity:

```ts filename="actions/create-project.ts"
import { defineAction } from "@agent-native/core/action";
import { track } from "@agent-native/core/tracking";
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";

export default defineAction({
  description: "Create a new project.",
  schema: z.object({
    name: z.string(),
    template: z.string().optional(),
  }),
  run: async ({ name, template }, ctx) => {
    const db = getDb();
    const [project] = await db
      .insert(schema.projects)
      .values({ name, template })
      .returning();

    // Pass ctx straight through: the event is attributed to ctx.userEmail and
    // joins the browser session the call came from.
    track("project.created", { name, template }, ctx);

    return { ok: true, projectId: project.id };
  },
});
```

Track calls are fire-and-forget — they return immediately and never block the action response.

## Client-side tracking {#client}

`track()` also works from browser/app code. Import the client twin from `@agent-native/core/client` and call it the same way — it POSTs the event to the framework route at `POST /_agent-native/track`, which forwards it to the **same** registered server-side providers (PostHog, Mixpanel, Amplitude, webhook). No analytics SDK ships to the browser and no provider keys are exposed client-side.

<Endpoint id="doc-block-1wp4rmn" title="The client tracking route" method="POST" path="/_agent-native/track" summary="Forward a browser event to the registered server-side providers" auth="Session required + same-origin/CSRF marker (set automatically by the client helper). Not an open analytics relay." params={[
  {
    "name": "name",
    "in": "body",
    "type": "string",
    "required": true,
    "description": "Event name. Capped at 200 characters."
  },
  {
    "name": "properties",
    "in": "body",
    "type": "object",
    "description": "Event properties (~16KB cap). `source: \"client\"` and the active `org_id` are added server-side."
  }
]}>

Identity is resolved **server-side** from the session — browser code never passes a `userId`. Fire-and-forget: never blocks the UI, never throws, swallows network errors. Oversized or malformed payloads are rejected.

</Endpoint>

```ts
import { track } from "@agent-native/core/client/analytics";
// e.g. inside a click handler or effect
track("checkout.completed", { total: 49.99, items: 3 });
```

Key differences from the [server `track()`](#track):

- **No identity argument.** The event is attributed server-side to the signed-in user (and the active org, as `org_id` in `properties`). Browser code never passes a `userId`.
- **`source: "client"`** is added to every event's properties so you can tell client-originated events apart from server ones.
- **Fire-and-forget.** It never blocks the UI, never throws, and swallows network errors.
- **Authenticated, first-party only.** The route requires a session and a same-origin/CSRF marker (set automatically by the helper), so it can't be used as an open analytics relay. `name` is capped at 200 characters and `properties` at ~16KB; oversized or malformed payloads are rejected.

This is distinct from the framework's internal browser telemetry (`trackEvent()` / automatic pageviews — see [Browser defaults](#browser-defaults) below), which powers Agent-Native's own product analytics. Use `track()` for your app's own analytics events that should reach your configured providers.

## Session replay {#session-replay}

Agent-Native apps can opt into first-party browser session replay without adding a second analytics SDK. Call `configureTracking()` once in the browser root and pass the Analytics public key plus collector endpoint:

```ts
import { configureTracking } from "@agent-native/core/client/analytics";
configureTracking({
  key: "anpk_...",
  endpoint: "https://analytics.example.com/api/analytics/track",
  sessionReplay: {
    enabled: true,
    sampleRate: 1,
  },
  getDefaultProps: (_event, props) => ({
    ...props,
    app: "my-app",
    template: "my-template",
  }),
});
```

When `sessionReplay.enabled` is truthy, the client dynamically imports `@rrweb/record` after startup and posts replay chunks to the replay endpoint. If `endpoint` ends in `/api/analytics/track` or `/track`, the replay endpoint is derived automatically as `/api/analytics/replay`. Override it explicitly with `sessionReplay.endpoint` when the replay collector lives somewhere else.

Agent-Native template roots already call `configureTracking()`. Hosted template deployments can turn replay on with Vite/Netlify environment variables, while library consumers should prefer the explicit `configureTracking({ key, endpoint, sessionReplay })` form above:

```bash
VITE_AGENT_NATIVE_ANALYTICS_PUBLIC_KEY=anpk_...
VITE_AGENT_NATIVE_ANALYTICS_ENDPOINT=https://analytics.example.com/api/analytics/track
VITE_AGENT_NATIVE_SESSION_REPLAY_ENABLED=true
VITE_AGENT_NATIVE_SESSION_REPLAY_SAMPLE_RATE=1
```

The browser helper also performs a best-effort, non-blocking read of the current Agent-Native auth session. Replay is signed-in-only by default: when `sessionReplay` is enabled, recording does not start unless the session resolves to a user email address. Signed-in replays include email-backed `userId`/`userEmail` plus `orgId`.

Set `sessionReplay.requireSignedInUser: false` or `VITE_AGENT_NATIVE_SESSION_REPLAY_REQUIRE_AUTH=false` only for an intentional anonymous replay deployment. When auth gating is disabled, anonymous recordings remain queryable by anonymous visitor, session, app/template, hostname, and path.

Session replay is sampled deterministically per browser session. A `sampleRate` of `1` records all eligible sessions; use lower values, such as `0.1`, when the eligible population is intentionally broad.

Privacy defaults are intentionally conservative but still useful for playback:

- Inputs are masked by default (`maskAllInputs: true`).
- Page text remains visible unless an element is marked with `.an-mask` or `data-an-mask`.
- Sensitive zones are blocked with selectors such as `[data-sensitive]`, `.an-block`, `.an-private`, `data-an-block`, `data-an-private`, and credit-card/password/SSN-like fields.
- URLs are scrubbed with the same `scrubUrl()` helper used by browser analytics.
- Replay capture is web-only and opt-in; it does not record native desktop screens.

Framework-owned iframes, including extensions and rendered email content, are included in replay capture. The same masking and blocking selectors apply inside recorded frames. A third-party cross-origin iframe remains opaque unless its own document runs a compatible rrweb recorder with cross-origin iframe recording enabled; browser origin boundaries cannot be bypassed from the parent page.

While recording, session replay also captures browser console output (`log`, `info`, `warn`, `error`, `debug`, plus window `error` / `unhandledrejection`) and network request metadata (`fetch` and XHR) as tagged rrweb custom events, so agents and the replay viewer can debug user-reported issues. Capture is on by default when replay is enabled; tune or disable it with the `sessionReplay.console` and `sessionReplay.network` options, each accepting a boolean or an options object (`{ maxEvents?: number }`, with `network` additionally accepting `captureErrorBodies` and `maxErrorBodyLength`). Request bodies and headers are never captured; response bodies are captured only as a bounded, redacted snippet for 5xx (server error) responses (`captureErrorBodies`, default true, capped at `maxErrorBodyLength` chars, default 2048) — non-5xx and network-failure responses never carry a body. URLs are scrubbed, messages are truncated, the recorder's own ingest/tracking traffic is excluded, and per-session budgets (1000 console / 2000 network events) add a truncation notice when exceeded.

The Analytics template stores replay metadata in SQL (`session_recordings`) and stores chunks through private blob refs (`session_replay_chunks`). Browsers and agents never receive provider URLs. Playback goes through scoped server routes and the default agent tools return summaries or bounded replay events, not raw chunk table access.

When you need to hand a private recording to an external agent, use the session detail page's **Copy for agent** control. It mints a two-hour, recording-scoped `agent_access` link, SSRs a small discovery payload on `/sessions/:recordingId`, and exposes only the agent context/events JSON APIs. The recording's visibility does not change.

Implementation note: the reusable layer is the core agent-access URL contract,
not a replay-specific UI abstraction. Use `createScopedAgentAccessGrant`,
`verifyScopedAgentAccessToken`, `buildAgentAccessUrl`, and
`buildAgentAccessApiUrl` for scoped SSR and JSON API links; keep replay storage,
diagnostics shaping, and access policy inside the Analytics template.

For local development only, Analytics can fall back to capped SQL inline chunks when private blob storage is unavailable. Production deployments should configure private or encrypted blob storage rather than relying on Postgres for replay payloads.

## Test identities {#test-identities}

QA and E2E accounts keep working everywhere, but they never count. `track()`, `identify()`, browser tracking, and session replay drop events from a test identity before any provider sees them. A test identity's server exceptions are kept, flagged `test_identity: true`, and delivered only to providers that set `acceptsTestIdentityExceptions` (Agent-Native Analytics), so they stay queryable without paging anyone. Non-auth email to a test identity is suppressed too: `sendEmail()` returns `{ status: "suppressed", reason: "test-identity" }` and logs the attempt, while sign-in, verification, password reset, and invitation mail still goes out. Every recipient line counts: test identities in `cc` and `bcc` are dropped and logged too. When `to` is a test identity, a real `cc` recipient takes its place; a `bcc` recipient never does, since the rest of `bcc` would see it. A send left with no `to` or `cc` recipient is suppressed whole, and every dropped address is logged.

`isTestIdentity(email)` from `@agent-native/core/server` is the one rule. It matches the reserved `.test`, `.invalid`, `.localhost`, and `.example` domains and the `+autoz` QA marker. Add more per deployment with `AGENT_NATIVE_TEST_IDENTITY_EMAILS`, a comma-separated list of exact addresses (`qa@corp.com`) or `@domain` entries (`@example.com`). `testIdentitySql(column)` is the same rule as a PostgreSQL/BigQuery expression for metric queries. Browsers never receive that list: the session response carries a server-resolved `testIdentity` flag that browser tracking, session replay, and exception capture honor alongside the built-in rules, and Agent-Native Analytics ingest applies the deployment list as well.

## Advanced: custom providers & internals {#advanced}

Most apps only need `track()` / `identify()` and a built-in provider. The rest of the surface — registering custom providers, the `TrackingProvider` interface, batching internals, and the framework's own browser telemetry — is below.

<details>
<summary><strong>Provider-registry API, interface, internals, and browser defaults</strong></summary>

### `registerTrackingProvider(provider)` {#register}

Register a custom provider for any analytics backend.

```ts
import { registerTrackingProvider } from "@agent-native/core/tracking";

registerTrackingProvider({
  name: "my-analytics",
  track(event) {
    // Send event to your backend
    fetch("https://analytics.example.com/events", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(event),
    }).catch(() => {});
  },
  identify(userId, traits) {
    // Optional — link user identity to future events
  },
  flush() {
    // Optional — called on graceful shutdown
  },
});
```

### `flushTracking()` {#flush}

Flush all providers. Call before process exit to ensure pending events are sent.

```ts
import { flushTracking } from "@agent-native/core/tracking";

await flushTracking();
```

### `unregisterTrackingProvider(name)` {#unregister}

Remove a provider by name. Returns `true` if the provider was found and removed.

### `listTrackingProviders()` {#list}

Returns the names of all registered providers.

### The TrackingProvider interface {#provider-interface}

```ts
interface TrackingProvider {
  name: string;
  track(event: TrackingEvent): void | Promise<void>;
  identify?(
    userId: string,
    traits?: Record<string, unknown>,
  ): void | Promise<void>;
  flush?(): void | Promise<void>;
}

interface TrackingEvent {
  name: string;
  properties?: Record<string, unknown>;
  timestamp?: string;
  userId?: string;
}
```

Only `name` and `track` are required. `identify` and `flush` are optional — implement them if your backend supports user identity and batched delivery.

### How it works {#internals}

- **Batched HTTP** — built-in providers enqueue events and flush every 10 seconds or when 50 events accumulate, whichever comes first. This minimizes outbound requests without losing data.
- **No SDK dependencies** — all built-in providers use raw `fetch()`. No PostHog SDK, no Mixpanel SDK, no Amplitude SDK. Keeps the framework lightweight.
- **Best-effort delivery** — provider errors are caught and logged. A failing analytics integration never crashes the caller or blocks request handling.
- **Global singleton** — the registry uses a `Symbol.for` key on `globalThis` so multiple ESM graph instances (dev-mode Vite + Nitro, symlinks) share one provider set.

### Browser defaults {#browser-defaults}

This covers the framework's own internal telemetry — mostly relevant to framework contributors and advanced template authors.

Template roots call `configureTracking()` once at startup. Browser events sent with `trackEvent()` automatically include app/template context plus the current LLM connection when the app can resolve it:

- `llm_connection` — normalized provider label such as `builder`, `anthropic`, `openai`, `google`, or `none`
- `llm_engine` — the engine id, for example `builder` or `ai-sdk:openai`
- `llm_model` — the selected/default model when known
- `llm_connection_source` — `app_secrets`, `settings`, or `env`
- `llm_connection_configured` — whether an LLM connection is available

The framework also tracks `builder_connect_clicked` when a user selects a Use Builder.io CTA, and the server-side Builder connect routes track started/succeeded/failed lifecycle events. One-click account creation adds `connect_url_kind: "provision"`; the sign-in popup also records the legacy `builder connect clicked`, which misses one-click clicks. Server-side setup changes also emit `llm_credential_changed` (`change_type`, normalized `provider`, `scope`, `credential_kind`, and optional `surface`) and `llm_default_model_changed` (`engine`, `model`, optional prior engine/model, `scope`, and `operation`). These events never include credential or endpoint values. `configureTracking()` is called automatically by the framework; you don't need to call it in your own template code.

</details>

## What's next

- [**Actions**](/docs/actions-overview) — where most tracking calls originate
- [**Server Plugins**](/docs/server-plugins) — `registerBuiltinProviders()` runs in the core-routes plugin at startup
- [**Security — Secrets Management**](/docs/security#secrets) — manage API keys for tracking providers
