Skip to main content

tracking

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).

Source facts

Repository
BuilderIO/agent-native
Last source activity
October 2, 2026 at 20:10
Detected SKILL.md language
English
Stars
7,065
Forks
640

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub