- name
- tracking
- description
- Server-side analytics tracking with pluggable providers. Use when adding analytics events, registering custom tracking providers, or configuring built-in providers (PostHog, Mixpanel, Amplitude, Webhook).
- scope
- dev
- metadata
- {"internal":true}
# Tracking
## Rule
The tracking system provides a single server-side `track()` call that fans out
to all registered providers, plus the browser-side `trackEvent()` counterpart.
Built-in providers auto-register from env vars - set the var and tracking
starts. Custom providers can be registered for any analytics backend. Both
surfaces are best-effort and never block request handling.
## How It Works
1. At server startup, `registerBuiltinProviders()` checks env vars and registers any configured providers.
2. Application code calls `track(eventName, properties, source)` from actions, plugins, or server routes.
3. The registry fans out the event to every registered provider. Errors are caught and logged -- a failing provider never crashes the caller.
4. Built-in providers batch HTTP calls (flush every 10 seconds or 50 events, whichever comes first).
## API
### `track(name, properties?, source?)`
Fire an analytics event. `source` is either a `{ userId, anonymousId, sessionId }`
meta object or an action's `ctx` passed straight through.
```ts
import { track } from "@agent-native/core/tracking";
// From an action — pass ctx; userId comes from ctx.userEmail.
run: async ({ name }, ctx) => {
track("meal.logged", { mealName: name, calories: 350 }, ctx);
};
// From a plugin or route with no ctx.
track(
"meal.logged",
{ mealName: "Salad", calories: 350 },
{ userId: "user@example.com" },
);
```
The caller's browser session comes from the ambient request context
(`RequestContext.browserSessionId`, set from the `X-Agent-Native-Session-Id`
header), so it resolves the same whether the UI called the action or the agent
did. Pass `sessionId` in the meta object to override it — routes that run
outside a request context, such as `/_agent-native/track`, do exactly that.
Providers map it to their own session field: `$session_id` for PostHog (which
joins the event to session replay), `session_id` as a 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.
### `identify(userId, traits?)`
Identify a user with traits. Forwarded to providers that support it.
```ts
import { identify } from "@agent-native/core/tracking";
identify("user@example.com", { plan: "pro", company: "ExampleCo" });
```
### `registerTrackingProvider(provider)`
Register a custom provider.
```ts
import { registerTrackingProvider } from "@agent-native/core/tracking";
registerTrackingProvider({
name: "my-analytics",
track(event) {
// Send event to your backend
},
identify(userId, traits) {
// Optional
},
flush() {
// Optional -- called on graceful shutdown
},
});
```
### `flushTracking()`
Flush all providers (call before process exit).
## Built-in Providers
Set the env var and the provider auto-registers at startup. No SDK dependencies -- all providers use raw HTTP.
| Provider | Env vars |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| PostHog | `POSTHOG_API_KEY` (required), `POSTHOG_HOST` (optional, defaults to `https://us.i.posthog.com`), `POSTHOG_ERROR_TRACKING=false` (optional opt-out) |
| Mixpanel | `MIXPANEL_TOKEN` |
| Amplitude | `AMPLITUDE_API_KEY` |
| Agent-Native Analytics | `AGENT_NATIVE_ANALYTICS_PUBLIC_KEY` (server), `AGENT_NATIVE_ANALYTICS_ENDPOINT` (optional, defaults to `https://analytics.agent-native.com/track`) |
| Webhook | `TRACKING_WEBHOOK_URL` (required), `TRACKING_WEBHOOK_AUTH` (optional, sent as `Authorization` header) |
Multiple providers can be active simultaneously. All receive every event.
Browser-side `trackEvent()` also forwards to Agent-Native Analytics when `VITE_AGENT_NATIVE_ANALYTICS_PUBLIC_KEY` is present. Use `VITE_AGENT_NATIVE_ANALYTICS_ENDPOINT` to override the default browser endpoint. The built-in Agent-Native Analytics sender is quiet on localhost/local dev by default; set `AGENT_NATIVE_ANALYTICS_ALLOW_LOCALHOST=true` only for an intentional local ingestion test.
## Error Capture
Exceptions fan out through `server/capture-error.ts` to every registered
backend - Sentry, PostHog, and the tracking providers - from one
`captureError()` call. Backends are additive: configuring a second one does not
displace the first, and no backend is required for the others to work.
When first-party Agent-Native Analytics is configured, browser
`configureTracking()` captures uncaught errors, unhandled rejections, and
manual `captureException()` calls as `$exception` events through `/track`.
On the server, the core route plugin registers the tracking capture provider:
`captureError()` calls `captureException()`, and the Agent-Native provider sends
the same event when the server public key is configured. Analytics groups both
into owner-scoped `error_issues` and `error_events`, shown in Monitoring ->
Errors and exposed to authenticated agents through `list-error-issues` and
`get-error-issue`. This remains available even when external Sentry is
unavailable or rate-limited.
Emit through `captureError()` / `captureException()`. Never hand-roll a
`track("$exception", …)`: each backend needs its own payload shape and the
providers build it.
- **Provider-agnostic wiring must not live in a provider plugin.** The Nitro
`error` hook is in `core-routes-plugin.ts`, not `sentry-plugin.ts`, because
that plugin returns early when no `SENTRY_DSN` is set — hooking route errors
there meant an app on any other backend silently reported none.
- **`captureError()` is the one noise and flood boundary.** The drop rules live
in `shared/error-noise.ts` (expected 4xx, access-control rejections, Lambda
`socket hang up`, third-party/extension/GTM stacks, stale chunks, opaque
`Script error.`); the Nitro hook, every explicit `captureError()` call, the
browser capture, Sentry `beforeSend`, and the analytics ingest all call
`classifyErrorNoise()`. After the filter, `server/capture-error.ts` folds
transient-database, database-credential, and configuration failures into one
aggregate per (class, route): the first event immediately, then one summary
per window carrying `suppressedCount`. Every drop is counted
(`getCaptureErrorStats()`); an error that matches no rule is reported every
time. A backend must hang off `registerErrorCaptureProvider`, never call a
provider SDK from a catch block, or it skips both. Two escape hatches keep a
rule from hiding a real failure: tag a capture
`reportExpected: "true"` (`REPORT_EXPECTED_FAILURE_TAG`) when your code raises
an access-control or 4xx failure as a failure (a runner whose grant was
lost), and give an explicit browser capture a `context` or `area` tag so a
stackless Safari/Firefox network error from it is not dropped as
unattributable. Stale-chunk failures are dropped because route-chunk-recovery
reloads on them; when it cannot (cooldown, desktop) it reports one
`RouteChunkRecoveryExhausted` per page session instead.
- **A backend can accept a malformed payload and still show a count.** PostHog
ingested the framework's camelCase `$exception` for a long time and rendered
empty, ungroupable issues — which reads as coverage, not as breakage. When
adding or changing a backend, check what an event looks like in its UI, not
just that the request returned 200.
- **Attribute the error.** Without a user id, server exceptions land under
`anonymous` and split one person in two against their browser events. Pass
`aiTraceId` for anything inside an agent run so the issue and the LLM trace
resolve to each other.
- **Every server capture carries a failure packet** at
`extra.failureContext` (`observability/failure-context.ts`): app, route,
action or automation name, `threadId`, `runId`, `requestId`, `userScope`
(`org` or `personal`, never an email), `threadUrl`
(`https://<app host>/?thread=<chat_threads.id>`), build, environment,
`errorCode`, `failureClass`. The boundary derives it from what the call site
already passes (`tags.action`, `extra.runId` / `threadId` / `request_id`,
`aiTraceId`) and from the ambient chat run, so a capture inside a run names
its thread with no change at the call site. Work that runs outside any
request (a scheduled automation) passes `failure: { threadId, automationName,
userScope }` on the capture. A run id also becomes `aiTraceId`. The Analytics
issue page links `threadUrl`; hand any agent the packet and it can run
`get-agent-thread-debug` with the `runId`. Never put an email, a token or a
prompt in it.
- **Rates are events, not log lines.** `tracking/failure-counters.ts` counts a
failure class per (event, bounded dimensions) and ships `count` per window
(first occurrence at once, the rest once a minute, `SUM(count)` is exact):
`action_error_counts` (`action_name`, `error_code`, `status_class`,
`action_source`) and `credential_state_counts` (`credential_state`,
`credential_subject`, `source`). Automation pauses and automatic resumes are
`automation_paused` / `automation_resumed` (`automation_hash`, never the
name), the client's open circuits are `action_circuit_tripped`, and a chat run
that ended on a credential problem is a `$ai_trace` with `credential_state`.
Attachment mint/resolve/delete outcomes (ok included) are
`attachment_outcome_counts` (`operation`, `status`, `reason`, `who_can_fix`),
and a template counts its own classes with `countOutcome("<thing>_counts",
{ ...tokens })` from `@agent-native/core/tracking` (Mail's Gmail cooldowns are
`gmail_cooldown_counts`, by `site`). Never one event per failure; a name that
is not `<thing>_counts` is dropped with one console error.
- **Browser outcomes are bounded, too.** `agent_run_outcome` is one event per
run (`outcome`, legacy `code`, `terminal_source`,
`verified_after_pipe_closed`, `resume_attempts`, `run_id`, `thread_id`):
every `interrupted` / `failed` / `unverified` run up to 30 per page, and
`succeeded` / `stopped` sampled at 10% with `sample_weight`.
`session_navigation` is one event per document that left because of the
session (`reason`, and for `signed_out` the `evidence`: `signed_out_body` or
`http_401`), never the destination. Both are emitted from the single place
that decides (`agentkit-protocol.ts`, `navigateForSession`), not the callers.
Symbolication is per-backend and not automatic: the framework uploads no source
maps to PostHog, so minified browser stacks stay minified there. Known gap, not
a bug to re-diagnose.
### Browser keys and the SSR shell
Public keys (`POSTHOG_PUBLIC_KEY`, the Sentry client DSN, and the first-party
Analytics public key) ship inside the
CDN-cached SSR shell — publishable and identical for every visitor. Server keys
never do, and are never a fallback for a public one: `POSTHOG_API_KEY` may be a
private key and this value lands in public HTML.
Browser errors post directly to the backend rather than through
`/_agent-native/track`, because that route requires a resolved session and
relaying would drop every signed-out crash.
When adding a client config field, update **both** `server/posthog-config.ts`
and the mirrored worker emitter in `deploy/build.ts` — the worker bundles a
string copy and cannot import the module, so a one-sided edit drops the config
silently in deployed builds. `posthog-config.spec.ts` pins the two outputs
together.
## MCP Server Events
The MCP server an app exposes reports its own usage. `packages/core/src/mcp/analytics.ts`
emits one event per protocol request, and the emission points sit in the shared
server builder (`build-server.ts`), so the HTTP mount and the stdio transport
report identically.
| Event | Fires on |
| ---------------------- | --------------------------------------------- |
| `$mcp_initialize` | the client/server handshake (HTTP mount only) |
| `$mcp_tools_list` | `tools/list` |
| `$mcp_tool_call` | `tools/call`, success or failure |
| `$mcp_resources_list` | `resources/list` |
| `$mcp_resource_read` | `resources/read` |
Names come from PostHog's MCP analytics vocabulary
(https://posthog.com/docs/mcp-analytics/events) on purpose, so PostHog's MCP
dashboards read these events with no mapping layer — but they go through
`track()` like every other event, so Mixpanel, Amplitude, a webhook, and
Agent-Native Analytics receive the same ones.
Shared properties: `$mcp_source` (`http` / `stdio`), `$mcp_server_name`,
`$mcp_server_version`, `$mcp_app_id`, `$mcp_client_name`, `$mcp_client_version`,
`$mcp_client_user_agent`, `$mcp_vendor_client`, `$mcp_protocol_version`. Per
event: `$mcp_tool_name`, `$mcp_tool_description`, `$mcp_tool_category`
(`read` / `write`), `$mcp_listed_tool_names`, `$mcp_duration_ms`,
`$mcp_is_error`, `$mcp_error_type`, `$mcp_error_message`, `$mcp_resource_name`,
`$mcp_resource_uri`.
- **A client's own name is only on the wire at `initialize`.** The mount is
stateless — one server per request — so no later event can recover it.
`$mcp_initialize` carries the `clientInfo` from the handshake; every other
event falls back to the 2026-era per-request `_meta` and the HTTP user agent,
and `$mcp_vendor_client` buckets both spellings onto one row.
- **A failed call is reported with its reason, not just `isError`.** The
`tools/call` handler renders "unknown tool", "forbidden scope", and a thrown
action error as the same shape of error result, so each sets `$mcp_error_type`
where it returns rather than having it guessed back out of the response text.
- **Payloads stay out.** `$mcp_response` is never emitted. `$mcp_parameters` is
off by default and redacted when on — tool arguments carry user content.
Off switches: `MCP_ANALYTICS=false` (`observability.mcpEvents`) disables the
events; `MCP_ANALYTICS_PARAMETERS=true` (`observability.mcpCaptureParameters`)
opts into arguments. They sit in `observability` with the other capture
switches, not in `analytics` — they gate every provider, not the first-party
Agent-Native Analytics sender whose key lives there.
## Default Baseline Events
Template roots call `configureTracking()` once during app startup. That installs default browser pageview tracking for hosted apps:
- Event: `pageview`
- Fires on initial load, `history.pushState`, `history.replaceState`, and `popstate`
- De-dupes repeated events for the same URL
View on GitHub