Tracking
Rule
The tracking system provides a single track() call that fans out to all registered providers. Built-in providers auto-register from env vars -- set the var and tracking starts. Custom providers can be registered for any analytics backend. Tracking is server-side only, best-effort, and never blocks request handling.
How It Works
- At server startup,
registerBuiltinProviders() checks env vars and registers any configured providers.
- Application code calls
track(eventName, properties, source) from actions, plugins, or server routes.
- The registry fans out the event to every registered provider. Errors are caught and logged -- a failing provider never crashes the caller.
- 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.
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.
import { identify } from "@agent-native/core/tracking";
identify("user@example.com", { plan: "pro", company: "ExampleCo" });
registerTrackingProvider(provider)
Register a custom provider.
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.
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.
- Every backend applies
server/error-noise-filter.ts. It holds
production-tuned drop rules (expected 4xx, access-control rejections, Lambda
freeze/thaw socket hang up). A backend that skips it receives a firehose;
the socket hang up rule alone is ~10k events/day.
- 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.
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) 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
- Includes
url, path, hostname, referrer, title, navigation_type, app, and inferred template
- Includes LLM connection context on browser events when known:
llm_connection (builder, anthropic, openai, etc.), llm_engine, llm_model, llm_connection_source, and llm_connection_configured
- Does not send first-party events from localhost/local dev
Visitor identity (anonymousId + sessionId)
Every browser-side trackEvent() POST to the Agent-Native Analytics /track endpoint includes:
anonymousId — persistent per-browser visitor ID stored in localStorage under agent-native.anonymous_id. Generated once and reused across sessions. Use this for unique-visitor and returning-visitor metrics.
sessionId — rotating per-visit ID stored in localStorage under agent-native.session_id, with a 30-minute idle timeout (matches GA4 / Mixpanel defaults). Use this for sessions-per-visitor, pages-per-session, and session-duration metrics.
userId — only set when the calling code passes properties.userId. Anonymous traffic leaves this NULL by design; anonymousId is the fallback.
These fields land in the analytics_events.anonymous_id, analytics_events.session_id, and analytics_events.user_id columns in the analytics template. Storage access is wrapped in try/catch — private-browsing / blocked-storage clients silently degrade to NULL rather than crashing the page.
Referral / viral attribution (first-touch)
configureTracking() also captures an anonymous visitor's first-touch referral context once, on first page load, and persists it across the signup boundary so the server-side signup event records where the user came from. This powers virality metrics for every template (Clips share links, Plans public pages, etc.).
Share-link params (set by whatever generates the link; read client-side only):
ref — referral source bucket, e.g. clip_share, plan_share
via — the referrer's stable user id (the clip/plan owner)
utm_source, utm_medium, utm_campaign, utm_content, utm_term
Client persistence (first-write-wins — an existing value is never overwritten):
localStorage key an_attribution and first-party cookie an_ft (path=/; max-age=2592000; SameSite=Lax, not HttpOnly — non-sensitive, written by client JS).
- Both store the same URL-encoded compact JSON (empty fields omitted, each value capped at 120 chars):
{ ref, via, utm_source, utm_medium, utm_campaign, utm_content, utm_term, landing_path, landing_referrer, landed_at }. landing_referrer is the host only of document.referrer (scrubbed; same-origin referrers are dropped).
getFirstTouchAttribution() (from @agent-native/core/client) returns the parsed object or null.
Signup event enrichment (server-side, from the an_ft cookie on the signup/OAuth-callback request, derived in packages/core/src/server/attribution.ts):
referral_source — ref if present, else derived: /share/… → clip_share; a plan public path (/p/, /plan/, /share-plan/) → plan_share; a non-empty external referring host → external; otherwise direct.
referrer_user (= via), referral_medium (= utm_medium), referral_campaign (= utm_campaign)
utm_source, utm_medium, utm_campaign, utm_content, utm_term (raw passthrough)
first_touch_path (= landing_path), landing_referrer
Attribution parsing is fully defensive and never blocks signup — a missing/malformed cookie falls back to referral_source: "direct".
Other framework-level baseline events:
session status from useSession(), with signed_in
action.response from the browser action transport, with action name,
browser-perceived duration and TTFB, response status/outcome, response size
when known, and parsed Server-Timing phases for framework readiness and
database work. Its request_id joins the exact browser and server events.
This separates server time from CDN/network/body overhead.
http.response from Nitro request/response hooks, with normalized path,
status, request duration, first-request-in-isolate cold marker, process age,
framework readiness wait, deploy/runtime fingerprint, database
connection/query counts and timings, retries, timeouts, and failures. It also
emits Server-Timing for app, startup, db, db-connect, and
db-slowest plus an X-Agent-Native-Request-Id correlation header where
applicable. Query text and parameters are never captured.
Database activity that begins during the first two minutes of process/plugin
initialization is reported separately as startup_db_* on the first
framework request that passes the readiness gate.
Slow, cold-isolate, server failures, and 4xx action routes are always
retained; fast successful requests default to 10% sampling. Override with
AGENT_NATIVE_HTTP_TELEMETRY_SAMPLE_RATE on the server and
VITE_AGENT_NATIVE_ACTION_TELEMETRY_SAMPLE_RATE in the browser.
signup from Better Auth user creation, with auth_provider, auth_user_id, and first-touch referral attribution (referral_source, referrer_user, referral_medium, referral_campaign, utm_*, first_touch_path, landing_referrer — see "Referral / viral attribution" above)
builder connect clicked and builder connect popup blocked from browser Connect Builder CTAs
builder connect started, builder connect succeeded, builder connect failed, builder disconnect succeeded, and builder disconnect failed from the Builder connection routes, with LLM connection context when resolvable
$ai_generation from instrumented agent loops, with PostHog AI Observability fields such as $ai_trace_id, $ai_session_id, $ai_model, $ai_provider, $ai_input_tokens, $ai_output_tokens, $ai_latency, $ai_total_cost_usd, and mirrored Agent-Native query fields such as run_id, thread_id, cost_cents_x100, duration_ms, tool_calls, and status. A bounded tools array contains names, start offsets, durations, statuses, and coarse error classes only; interrupted tools and failed runs remain visible, and delegated runs include protocol/task/parent-run/parent-turn correlation. Prompt, tool argument, result, and output content is excluded unless captureToolResults is opted in (see the observability skill), in which case each failed tool call also carries a error_message string truncated to 500 characters and already scrubbed of bearer tokens, API keys, and key/value secret patterns.
For new lifecycle events, call track() server-side when the server is the source of truth, and trackEvent() client-side only for browser interactions.
Lifecycle taxonomy
Lifecycle event names use lowercase snake_case; lifecycle properties use the
same convention. The shared dimensions are app_name, template_name,
user_id, user_email, workspace_id, session_id, output_id,
output_type, source, and referrer. App/template values are canonical ids
without the agent-native- prefix. Identity is also stored in the event's
top-level user/session columns for joins.
The canonical lifecycle events are app_entered, core_action_started,
core_action_completed, core_action_failed, output_viewed,
output_shared, cta_clicked, return_usage, and cross_app_used. The
framework also emits action_started, action_completed, and
action_failed for mutating defineAction() calls so UI, agent, MCP, A2A,
automation, and CLI activity share one action-level trail. Read-only actions
and high-frequency refresh, polling, navigation, and application-state actions
are excluded.
App entry is emitted once per app and browser session. Tracking is action-level
only: it does not install mouse, pointer, scroll, keypress, or DOM autocapture.
Existing legacy events remain available and emit their canonical lifecycle
counterpart where the meaning is unambiguous, so downstream dashboards can
migrate without losing historical names.
Provider Interface
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;
}
Design Decisions
- globalThis singleton -- the registry uses a
Symbol.for key on globalThis so multiple ESM graph instances (dev-mode Vite + Nitro, symlinks) share one provider set.
- Best-effort fan-out -- provider errors are caught and logged, never propagated. A broken analytics integration must not break app functionality.
- Batched HTTP -- built-in providers enqueue events and flush every 10 seconds or 50 events, minimizing outbound requests.
- NOT bridged to the event bus -- tracking and the event bus are separate concerns. The event bus is for triggering automations; tracking is for analytics. Do not subscribe to
track() calls from the event bus or vice versa.
Key Files
| File |
Purpose |
packages/core/src/tracking/registry.ts |
track(), identify(), registerTrackingProvider(), flushTracking() |
packages/core/src/tracking/providers.ts |
Built-in providers (PostHog, Mixpanel, Amplitude, Agent-Native Analytics, Webhook) and registerBuiltinProviders() |
packages/core/src/tracking/types.ts |
TrackingEvent and TrackingProvider interfaces |
packages/core/src/tracking/posthog-exception.ts |
$exception_list builder + stack-frame parser (isomorphic: server and browser) |
packages/core/src/tracking/redaction.ts |
Shared bounding/redaction helpers used by every exception emitter |
packages/core/src/server/error-noise-filter.ts |
Provider-agnostic drop rules, applied by both Sentry beforeSend and the route error hook |
packages/core/src/server/posthog-config.ts |
Public browser PostHog config (mirrored in deploy/build.ts) |
Related Skills
secrets -- API keys for tracking providers can be registered as secrets
server-plugins -- registerBuiltinProviders() is called by the core-routes plugin at startup
actions -- call track() from action handlers to record user/agent activity
1---2name: tracking3description: 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).4---56# Tracking78## Rule910The tracking system provides a single `track()` call that fans out to all registered providers. Built-in providers auto-register from env vars -- set the var and tracking starts. Custom providers can be registered for any analytics backend. Tracking is server-side only, best-effort, and never blocks request handling.1112## How It Works13141. At server startup, `registerBuiltinProviders()` checks env vars and registers any configured providers.152. Application code calls `track(eventName, properties, source)` from actions, plugins, or server routes.163. The registry fans out the event to every registered provider. Errors are caught and logged -- a failing provider never crashes the caller.174. Built-in providers batch HTTP calls (flush every 10 seconds or 50 events, whichever comes first).1819## API2021### `track(name, properties?, source?)`2223Fire an analytics event. `source` is either a `{ userId, anonymousId, sessionId }`24meta object or an action's `ctx` passed straight through.2526```ts27import { track } from "@agent-native/core/tracking";2829// From an action — pass ctx; userId comes from ctx.userEmail.30run: async ({ name }, ctx) => {31 track("meal.logged", { mealName: name, calories: 350 }, ctx);32};3334// From a plugin or route with no ctx.35track(36 "meal.logged",37 { mealName: "Salad", calories: 350 },38 { userId: "user@example.com" },39);40```4142The caller's browser session comes from the ambient request context43(`RequestContext.browserSessionId`, set from the `X-Agent-Native-Session-Id`44header), so it resolves the same whether the UI called the action or the agent45did. Pass `sessionId` in the meta object to override it — routes that run46outside a request context, such as `/_agent-native/track`, do exactly that.47Providers map it to their own session field: `$session_id` for PostHog (which48joins the event to session replay), `session_id` as a property for Mixpanel and49Amplitude, a top-level `sessionId` for webhooks and Agent-Native Analytics. It50is absent for callers with no browser — cron, CLI, MCP, A2A.5152### `identify(userId, traits?)`5354Identify a user with traits. Forwarded to providers that support it.5556```ts57import { identify } from "@agent-native/core/tracking";5859identify("user@example.com", { plan: "pro", company: "ExampleCo" });60```6162### `registerTrackingProvider(provider)`6364Register a custom provider.6566```ts67import { registerTrackingProvider } from "@agent-native/core/tracking";6869registerTrackingProvider({70 name: "my-analytics",71 track(event) {72 // Send event to your backend73 },74 identify(userId, traits) {75 // Optional76 },77 flush() {78 // Optional -- called on graceful shutdown79 },80});81```8283### `flushTracking()`8485Flush all providers (call before process exit).8687## Built-in Providers8889Set the env var and the provider auto-registers at startup. No SDK dependencies -- all providers use raw HTTP.9091| Provider | Env vars |92| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |93| PostHog | `POSTHOG_API_KEY` (required), `POSTHOG_HOST` (optional, defaults to `https://us.i.posthog.com`), `POSTHOG_ERROR_TRACKING=false` (optional opt-out) |94| Mixpanel | `MIXPANEL_TOKEN` |95| Amplitude | `AMPLITUDE_API_KEY` |96| Agent-Native Analytics | `AGENT_NATIVE_ANALYTICS_PUBLIC_KEY` (server), `AGENT_NATIVE_ANALYTICS_ENDPOINT` (optional, defaults to `https://analytics.agent-native.com/track`) |97| Webhook | `TRACKING_WEBHOOK_URL` (required), `TRACKING_WEBHOOK_AUTH` (optional, sent as `Authorization` header) |9899Multiple providers can be active simultaneously. All receive every event.100101Browser-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.102103## Error Capture104105Exceptions fan out through `server/capture-error.ts` to every registered106backend — Sentry, PostHog, and the tracking providers — from one `captureError()`107call. Backends are additive: configuring a second one does not displace the108first, and no backend is required for the others to work.109110Emit through `captureError()` / `captureException()`. Never hand-roll a111`track("$exception", …)`: each backend needs its own payload shape and the112providers build it.113114- **Provider-agnostic wiring must not live in a provider plugin.** The Nitro115 `error` hook is in `core-routes-plugin.ts`, not `sentry-plugin.ts`, because116 that plugin returns early when no `SENTRY_DSN` is set — hooking route errors117 there meant an app on any other backend silently reported none.118- **Every backend applies `server/error-noise-filter.ts`.** It holds119 production-tuned drop rules (expected 4xx, access-control rejections, Lambda120 freeze/thaw `socket hang up`). A backend that skips it receives a firehose;121 the `socket hang up` rule alone is ~10k events/day.122- **A backend can accept a malformed payload and still show a count.** PostHog123 ingested the framework's camelCase `$exception` for a long time and rendered124 empty, ungroupable issues — which reads as coverage, not as breakage. When125 adding or changing a backend, check what an event looks like in its UI, not126 just that the request returned 200.127- **Attribute the error.** Without a user id, server exceptions land under128 `anonymous` and split one person in two against their browser events. Pass129 `aiTraceId` for anything inside an agent run so the issue and the LLM trace130 resolve to each other.131132Symbolication is per-backend and not automatic: the framework uploads no source133maps to PostHog, so minified browser stacks stay minified there. Known gap, not134a bug to re-diagnose.135136### Browser keys and the SSR shell137138Public keys (`POSTHOG_PUBLIC_KEY`, the Sentry client DSN) ship inside the139CDN-cached SSR shell — publishable and identical for every visitor. Server keys140never do, and are never a fallback for a public one: `POSTHOG_API_KEY` may be a141private key and this value lands in public HTML.142143Browser errors post directly to the backend rather than through144`/_agent-native/track`, because that route requires a resolved session and145relaying would drop every signed-out crash.146147When adding a client config field, update **both** `server/posthog-config.ts`148and the mirrored worker emitter in `deploy/build.ts` — the worker bundles a149string copy and cannot import the module, so a one-sided edit drops the config150silently in deployed builds. `posthog-config.spec.ts` pins the two outputs151together.152153## MCP Server Events154155The MCP server an app exposes reports its own usage. `packages/core/src/mcp/analytics.ts`156emits one event per protocol request, and the emission points sit in the shared157server builder (`build-server.ts`), so the HTTP mount and the stdio transport158report identically.159160| Event | Fires on |161| ---------------------- | --------------------------------------------- |162| `$mcp_initialize` | the client/server handshake (HTTP mount only) |163| `$mcp_tools_list` | `tools/list` |164| `$mcp_tool_call` | `tools/call`, success or failure |165| `$mcp_resources_list` | `resources/list` |166| `$mcp_resource_read` | `resources/read` |167168Names come from PostHog's MCP analytics vocabulary169(https://posthog.com/docs/mcp-analytics/events) on purpose, so PostHog's MCP170dashboards read these events with no mapping layer — but they go through171`track()` like every other event, so Mixpanel, Amplitude, a webhook, and172Agent-Native Analytics receive the same ones.173174Shared properties: `$mcp_source` (`http` / `stdio`), `$mcp_server_name`,175`$mcp_server_version`, `$mcp_app_id`, `$mcp_client_name`, `$mcp_client_version`,176`$mcp_client_user_agent`, `$mcp_vendor_client`, `$mcp_protocol_version`. Per177event: `$mcp_tool_name`, `$mcp_tool_description`, `$mcp_tool_category`178(`read` / `write`), `$mcp_listed_tool_names`, `$mcp_duration_ms`,179`$mcp_is_error`, `$mcp_error_type`, `$mcp_error_message`, `$mcp_resource_name`,180`$mcp_resource_uri`.181182- **A client's own name is only on the wire at `initialize`.** The mount is183 stateless — one server per request — so no later event can recover it.184 `$mcp_initialize` carries the `clientInfo` from the handshake; every other185 event falls back to the 2026-era per-request `_meta` and the HTTP user agent,186 and `$mcp_vendor_client` buckets both spellings onto one row.187- **A failed call is reported with its reason, not just `isError`.** The188 `tools/call` handler renders "unknown tool", "forbidden scope", and a thrown189 action error as the same shape of error result, so each sets `$mcp_error_type`190 where it returns rather than having it guessed back out of the response text.191- **Payloads stay out.** `$mcp_response` is never emitted. `$mcp_parameters` is192 off by default and redacted when on — tool arguments carry user content.193194Off switches: `MCP_ANALYTICS=false` (`observability.mcpEvents`) disables the195events; `MCP_ANALYTICS_PARAMETERS=true` (`observability.mcpCaptureParameters`)196opts into arguments. They sit in `observability` with the other capture197switches, not in `analytics` — they gate every provider, not the first-party198Agent-Native Analytics sender whose key lives there.199200## Default Baseline Events201202Template roots call `configureTracking()` once during app startup. That installs default browser pageview tracking for hosted apps:203204- Event: `pageview`205- Fires on initial load, `history.pushState`, `history.replaceState`, and `popstate`206- De-dupes repeated events for the same URL207- Includes `url`, `path`, `hostname`, `referrer`, `title`, `navigation_type`, `app`, and inferred `template`208- Includes LLM connection context on browser events when known: `llm_connection` (`builder`, `anthropic`, `openai`, etc.), `llm_engine`, `llm_model`, `llm_connection_source`, and `llm_connection_configured`209- Does not send first-party events from localhost/local dev210211### Visitor identity (`anonymousId` + `sessionId`)212213Every browser-side `trackEvent()` POST to the Agent-Native Analytics `/track` endpoint includes:214215- `anonymousId` — persistent per-browser visitor ID stored in `localStorage` under `agent-native.anonymous_id`. Generated once and reused across sessions. Use this for unique-visitor and returning-visitor metrics.216- `sessionId` — rotating per-visit ID stored in `localStorage` under `agent-native.session_id`, with a 30-minute idle timeout (matches GA4 / Mixpanel defaults). Use this for sessions-per-visitor, pages-per-session, and session-duration metrics.217- `userId` — only set when the calling code passes `properties.userId`. Anonymous traffic leaves this NULL by design; `anonymousId` is the fallback.218219These fields land in the `analytics_events.anonymous_id`, `analytics_events.session_id`, and `analytics_events.user_id` columns in the analytics template. Storage access is wrapped in try/catch — private-browsing / blocked-storage clients silently degrade to NULL rather than crashing the page.220221### Referral / viral attribution (first-touch)222223`configureTracking()` also captures an anonymous visitor's **first-touch** referral context once, on first page load, and persists it across the signup boundary so the server-side `signup` event records where the user came from. This powers virality metrics for every template (Clips share links, Plans public pages, etc.).224225**Share-link params** (set by whatever generates the link; read client-side only):226227- `ref` — referral source bucket, e.g. `clip_share`, `plan_share`228- `via` — the referrer's stable user id (the clip/plan owner)229- `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`230231**Client persistence** (first-write-wins — an existing value is never overwritten):232233- `localStorage` key `an_attribution` and first-party cookie `an_ft` (`path=/; max-age=2592000; SameSite=Lax`, not HttpOnly — non-sensitive, written by client JS).234- Both store the same URL-encoded compact JSON (empty fields omitted, each value capped at 120 chars): `{ ref, via, utm_source, utm_medium, utm_campaign, utm_content, utm_term, landing_path, landing_referrer, landed_at }`. `landing_referrer` is the **host only** of `document.referrer` (scrubbed; same-origin referrers are dropped).235- `getFirstTouchAttribution()` (from `@agent-native/core/client`) returns the parsed object or `null`.236237**Signup event enrichment** (server-side, from the `an_ft` cookie on the signup/OAuth-callback request, derived in `packages/core/src/server/attribution.ts`):238239- `referral_source` — `ref` if present, else derived: `/share/…` → `clip_share`; a plan public path (`/p/`, `/plan/`, `/share-plan/`) → `plan_share`; a non-empty external referring host → `external`; otherwise `direct`.240- `referrer_user` (= `via`), `referral_medium` (= `utm_medium`), `referral_campaign` (= `utm_campaign`)241- `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term` (raw passthrough)242- `first_touch_path` (= `landing_path`), `landing_referrer`243244Attribution parsing is fully defensive and never blocks signup — a missing/malformed cookie falls back to `referral_source: "direct"`.245246Other framework-level baseline events:247248- `session status` from `useSession()`, with `signed_in`249- `action.response` from the browser action transport, with action name,250 browser-perceived duration and TTFB, response status/outcome, response size251 when known, and parsed `Server-Timing` phases for framework readiness and252 database work. Its `request_id` joins the exact browser and server events.253 This separates server time from CDN/network/body overhead.254- `http.response` from Nitro request/response hooks, with normalized path,255 status, request duration, first-request-in-isolate cold marker, process age,256 framework readiness wait, deploy/runtime fingerprint, database257 connection/query counts and timings, retries, timeouts, and failures. It also258 emits `Server-Timing` for `app`, `startup`, `db`, `db-connect`, and259 `db-slowest` plus an `X-Agent-Native-Request-Id` correlation header where260 applicable. Query text and parameters are never captured.261 Database activity that begins during the first two minutes of process/plugin262 initialization is reported separately as `startup_db_*` on the first263 framework request that passes the readiness gate.264 Slow, cold-isolate, server failures, and 4xx action routes are always265 retained; fast successful requests default to 10% sampling. Override with266 `AGENT_NATIVE_HTTP_TELEMETRY_SAMPLE_RATE` on the server and267 `VITE_AGENT_NATIVE_ACTION_TELEMETRY_SAMPLE_RATE` in the browser.268- `signup` from Better Auth user creation, with `auth_provider`, `auth_user_id`, and first-touch referral attribution (`referral_source`, `referrer_user`, `referral_medium`, `referral_campaign`, `utm_*`, `first_touch_path`, `landing_referrer` — see "Referral / viral attribution" above)269- `builder connect clicked` and `builder connect popup blocked` from browser Connect Builder CTAs270- `builder connect started`, `builder connect succeeded`, `builder connect failed`, `builder disconnect succeeded`, and `builder disconnect failed` from the Builder connection routes, with LLM connection context when resolvable271- `$ai_generation` from instrumented agent loops, with PostHog AI Observability fields such as `$ai_trace_id`, `$ai_session_id`, `$ai_model`, `$ai_provider`, `$ai_input_tokens`, `$ai_output_tokens`, `$ai_latency`, `$ai_total_cost_usd`, and mirrored Agent-Native query fields such as `run_id`, `thread_id`, `cost_cents_x100`, `duration_ms`, `tool_calls`, and `status`. A bounded `tools` array contains names, start offsets, durations, statuses, and coarse error classes only; interrupted tools and failed runs remain visible, and delegated runs include protocol/task/parent-run/parent-turn correlation. Prompt, tool argument, result, and output content is excluded unless `captureToolResults` is opted in (see the `observability` skill), in which case each failed tool call also carries a `error_message` string truncated to 500 characters and already scrubbed of bearer tokens, API keys, and key/value secret patterns.272273For new lifecycle events, call `track()` server-side when the server is the source of truth, and `trackEvent()` client-side only for browser interactions.274275### Lifecycle taxonomy276277Lifecycle event names use lowercase `snake_case`; lifecycle properties use the278same convention. The shared dimensions are `app_name`, `template_name`,279`user_id`, `user_email`, `workspace_id`, `session_id`, `output_id`,280`output_type`, `source`, and `referrer`. App/template values are canonical ids281without the `agent-native-` prefix. Identity is also stored in the event's282top-level user/session columns for joins.283284The canonical lifecycle events are `app_entered`, `core_action_started`,285`core_action_completed`, `core_action_failed`, `output_viewed`,286`output_shared`, `cta_clicked`, `return_usage`, and `cross_app_used`. The287framework also emits `action_started`, `action_completed`, and288`action_failed` for mutating `defineAction()` calls so UI, agent, MCP, A2A,289automation, and CLI activity share one action-level trail. Read-only actions290and high-frequency refresh, polling, navigation, and application-state actions291are excluded.292293App entry is emitted once per app and browser session. Tracking is action-level294only: it does not install mouse, pointer, scroll, keypress, or DOM autocapture.295Existing legacy events remain available and emit their canonical lifecycle296counterpart where the meaning is unambiguous, so downstream dashboards can297migrate without losing historical names.298299## Provider Interface300301```ts302interface TrackingProvider {303 name: string;304 track(event: TrackingEvent): void | Promise<void>;305 identify?(306 userId: string,307 traits?: Record<string, unknown>,308 ): void | Promise<void>;309 flush?(): void | Promise<void>;310}311312interface TrackingEvent {313 name: string;314 properties?: Record<string, unknown>;315 timestamp?: string;316 userId?: string;317}318```319320## Design Decisions321322- **globalThis singleton** -- the registry uses a `Symbol.for` key on globalThis so multiple ESM graph instances (dev-mode Vite + Nitro, symlinks) share one provider set.323- **Best-effort fan-out** -- provider errors are caught and logged, never propagated. A broken analytics integration must not break app functionality.324- **Batched HTTP** -- built-in providers enqueue events and flush every 10 seconds or 50 events, minimizing outbound requests.325- **NOT bridged to the event bus** -- tracking and the event bus are separate concerns. The event bus is for triggering automations; tracking is for analytics. Do not subscribe to `track()` calls from the event bus or vice versa.326327## Key Files328329| File | Purpose |330| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |331| `packages/core/src/tracking/registry.ts` | `track()`, `identify()`, `registerTrackingProvider()`, `flushTracking()` |332| `packages/core/src/tracking/providers.ts` | Built-in providers (PostHog, Mixpanel, Amplitude, Agent-Native Analytics, Webhook) and `registerBuiltinProviders()` |333| `packages/core/src/tracking/types.ts` | `TrackingEvent` and `TrackingProvider` interfaces |334| `packages/core/src/tracking/posthog-exception.ts` | `$exception_list` builder + stack-frame parser (isomorphic: server and browser) |335| `packages/core/src/tracking/redaction.ts` | Shared bounding/redaction helpers used by every exception emitter |336| `packages/core/src/server/error-noise-filter.ts` | Provider-agnostic drop rules, applied by both Sentry `beforeSend` and the route error hook |337| `packages/core/src/server/posthog-config.ts` | Public browser PostHog config (mirrored in `deploy/build.ts`) |338339## Related Skills340341- `secrets` -- API keys for tracking providers can be registered as secrets342- `server-plugins` -- `registerBuiltinProviders()` is called by the core-routes plugin at startup343- `actions` -- call `track()` from action handlers to record user/agent activity