- 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.
An app served from a hosted beta host (`beta.*.agent-native.com`) reports to beta Analytics: `resolveLaneEndpoint` moves an endpoint on a production lane host to its beta lane for browser events, auth-page events, replay uploads, and server events. A new sender that reads the analytics endpoint must pass it through `resolveLaneEndpoint` too, or beta traffic splits. Beta and production Analytics share one database, so an ingest change runs on beta traffic first, and checking it on beta proves the merged code rather than production's. An endpoint on any other host is left as configured.
## 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, every
`stopped` run up to its own 30 (so stops never crowd out failures), and
`succeeded` sampled at 10% with `sample_weight`. A turn the server refuses
at its start (no model connected, a 5xx) is a `failed` run too, with
`terminal_source: local` and the refused turn's id. A failed or
interrupted run adds its `cause` from `AGENT_TROUBLE_CAUSES` when one fits
its `code`, and Analytics groups the rest by `code`. A code that is not
an identifier is sent as `unrecognized_code` (`agentErrorCodeForTelemetry`),
because a thrower's `data.code` can be a sentence. It never carries the
run's message, not even redacted: no pattern can tell an unquoted document
or person's name from the words around it, so a message stays out of every
event. A provider error is `provider_error` only when its code
came from a structured HTTP status (`http_5xx`, including a status on a
wrapped cause); a status that appears only in message text is not read. `agent_feedback_submitted` (`sentiment`,
`run_id`, `thread_id`) is the browser's copy of a thumbs rating, because
`$ai_feedback` has no browser session. Every `pageview` carries
`agent_signals: 1` (`AGENT_SIGNALS_PAGEVIEW_PROPERTY`): clients before it
sampled stops at 10% and sent no ratings or `page_load_id`, so Analytics
reads a session's cancelled runs, thumbs-down, and quick backs as measured
only after seeing the marker and while no unmarked pageview or sampled stop
shares the session (tabs share one session id), and counts only stops sent
unsampled. Keep all three guarantees while the marker ships. Every
`pageview` also carries `page_load_id` (`PAGE_LOAD_PAGEVIEW_PROPERTY`),
the same until the page reloads, because quick backs compare pages only
within one page load.
`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`; a refused
start from the transport's start-run catch), not the callers.
`agent_chat_stuck_detected` fires once per run and only while the stuck
banner shows, so Analytics' stuck chats are ones the person saw. It carries
`reason`, `dispatchMode`, `hasInFlightWork` and `heartbeatSinceSec`, so a run
the server still holds (a live background worker) reads differently from a
dead one.
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`
GitHub에서 보기