Skip to content

Analytics ​

Primitive includes built-in analytics. Events are buffered on the client, batched over WebSocket, persisted server-side, and made available through CLI commands, REST endpoints, the Admin Console, and server functions. The platform tracks daily/weekly/monthly active users and the document, permission, function, prompt, and integration lifecycle automatically — your app can ship with analytics on day one and add custom events later.

This page covers what's tracked out of the box, how to emit your own events, and how to read analytics back from the CLI, the REST API, a server function and the Admin Console.

What's Tracked Automatically ​

You get working analytics by initializing the client with default options. No logEvent calls required.

Client-Side Auto Events ​

The client emits these lifecycle events automatically. All are enabled by default.

ActionFeatureWhen it fires
user_active_dailysessionFirst authenticated activity on a UTC day
user_returnedsessionApp returns to the foreground after at least minResumeMs (default 5 min) away
session_endsessionThe app or tab closes, or the client is torn down (records duration_ms)
sync_errorsyncOutbound sync fails (rate-limited, default 30s minimum interval)
blob_upload_started / _succeeded / _failedblobsBlob upload lifecycle

Server-Side Events ​

The platform emits these from the server. No client code required.

CategoryExamples
Documentsdocument.created, document.viewed (fires on every document info fetch, including each open), document.opened (alias-resolved opens), document.updated (metadata changes — content edits aren't individually evented), document.deleted, document.tag_added, document.tag_removed
Permissionspermission.granted, permission.revoked, permission.pending.cancelled, ownership.transferred
Access requestsaccess_request.created, access_request.approved, access_request.denied
Invitationsinvitation.sent, invitation.cancelled, invitation.declined
Auth & Userssession.refreshed, user.removed, user.role_changed, and API-token created / revoked (feature token)
Server functionsfunction.invoke (feature functions) — one per HTTP invoke or task start, attributed to the caller, with the function key, the version that ran and its status (started for a task start)
Promptsprompt.executed
Integrationsintegration.invoke

Function and prompt events also record duration_ms, and prompt events record LLM token counts (input_tokens, output_tokens, total_tokens) when available. A prompt event also records cost — USD, as the provider reported it — when the provider reports one; today that is a decisions prompt, while a chat run reports no price. A run with no reported cost is recorded as cost-unknown, not as zero, so a window's cost total is the total of the runs that really reported a price and never reads cheaper than the runs it describes. A trigger fire (webhook or cron) has no caller, so it writes no per-user event — its record is the function's run row.

Auto-Populated Fields ​

Every event — auto or custom — gets these fields populated automatically: tenant_id, route, device_type, os_name, os_version, browser_name, browser_version, plan, connection_id.

Offline Persistence ​

Events are persisted on the device while offline and flushed on reconnect. A rate limiter caps emission at roughly 300 events per minute with burst limiting; events over the cap are dropped silently. No code required.

Emitting Custom Events ​

Log app-specific events from the client:

ts
client.analytics.logEvent({
  action: "photo_uploaded",
  feature: "gallery",
  user_ulid: currentUserUlid,
});
swift
await client.analytics.logEventAsync(AnalyticsEventInput(
  action: "photo_uploaded",
  feature: "gallery",
  user_ulid: currentUserUlid
))

action and user_ulid are required. Use the verb_noun convention for action names (photo_uploaded, report_generated, settings_changed) and group related events under a feature so per-feature dashboards work.

Adding Context ​

Pass a context_json object for per-event debug data. The serialized payload is bounded at 1 KiB, so keep it small — don't dump request bodies or full reports.

ts
client.analytics.logEvent({
  action: "search_executed",
  feature: "search",
  user_ulid: currentUserUlid,
  context_json: {
    query: "quarterly report",
    resultCount: 42,
  },
});
swift
await client.analytics.logEventAsync(AnalyticsEventInput(
  action: "search_executed",
  feature: "search",
  user_ulid: currentUserUlid,
  context_json: [
    "query": "quarterly report",
    "resultCount": 42,
  ]
))

Pre-Auth Events ​

Events without an authenticated user are dropped silently. To track activity on landing pages and sign-up flows, pass the client's unauthenticated-user constant as the user_ulid — each client exports one, shown below:

ts
client.analytics.logEvent({
  action: "landing_page_view",
  feature: "onboarding",
  user_ulid: ANALYTICS_UNAUTHENTICATED_USER,
});
swift
await client.analytics.logEventAsync(AnalyticsEventInput(
  action: "landing_page_view",
  feature: "onboarding",
  user_ulid: AnalyticsEventInput.unauthenticatedUser
))

Use sparingly — most analytics should be tied to real users.

Snapshots ​

logSnapshot records a single state snapshot. The user is auto-resolved; if no user is signed in the call is a no-op (no error).

ts
client.analytics.logSnapshot({ screen: "settings", tab: "billing" });
swift
await client.analytics.logSnapshotAsync(context: ["screen": "settings", "tab": "billing"])

This emits an event with action: "_snapshot", feature: "_state", and your payload as context_json.

Plan and App Version Overrides ​

If your app reports plan/version dynamically (e.g. after an in-app upgrade), set them once on the client and they flow into every subsequent event:

ts
client.analytics.setPlanOverride("pro");
client.analytics.setAppVersionOverride("2.1.4");

// Pass null to clear an override
client.analytics.setPlanOverride(null);
swift
await client.analytics.setPlanOverrideAsync("pro")
await client.analytics.setAppVersionOverrideAsync("2.1.4")

// Pass nil to clear an override
await client.analytics.setPlanOverrideAsync(nil)

What to Avoid ​

  • Don't log without user_ulid — the runtime drops the event silently.
  • Don't log high-frequency telemetry (mouse moves, scroll, keystrokes) — the rate limiter caps at roughly 300 events/min and will drop the rest.
  • Don't add your own teardown flush — the client flushes pending events and emits session_end for you.

Writing Events from a Server Function ​

A server function records an event with ctx.api.analytics.writeForUser. The event is attributed to the subject user you name, not to whoever is running the function — so a function a webhook or a schedule fired, which has no caller, can still record activity against the member it acted for:

ts
await ctx.api.analytics.writeForUser({
  body: { userId: input.userId, action: "order_placed", feature: "orders" },
});

userId and action are required; feature, route and a context object are optional, as on the client. The subject must be a member of this app — a user id from another app is refused exactly as an id that never existed is. appId and userId are reserved, so a context object of your own cannot rewrite whose activity the event is.

The route, POST /app/{appId}/api/analytics/write-for-user, is reachable only from a function: a member, admin or owner calling it directly is refused 403 with FUNCTION_ROUTE_FUNCTION_ONLY. Inside a function it needs no capability line.

Configuring Auto Events ​

Pass an analyticsAutoEvents option to the client constructor to fine-tune the lifecycle events per feature:

ts
const client = await initializeClient({
  apiUrl: "https://primitiveapi.com",
  wsUrl: "wss://primitiveapi.com",
  appId: "YOUR_APP_ID",
  analyticsAutoEvents: {
    dailyAuth: true,
    returnActive: true,
    minResumeMs: 5 * 60 * 1000, // gap before another user_returned fires
    sessionEnd: true,
    syncErrors: { enabled: true, minIntervalMs: 30_000 },
    blobUploads: { start: false, success: true, failure: true },
  },
});
swift
let client = JsBaoClient(options: JsBaoClientOptions(
  apiUrl: "https://primitiveapi.com",
  wsUrl: "wss://primitiveapi.com",
  appId: "YOUR_APP_ID",
  analyticsAutoEvents: AnalyticsAutoEventsConfig(
    dailyAuth: true,
    returnActive: true,
    minResume: 5 * 60, // seconds before another user_returned can fire
    syncErrorsEnabled: true,
    syncErrorsMinInterval: 30,
    blobUploadsStart: false,
    blobUploadsSuccess: true,
    blobUploadsFailure: true,
    sessionEnd: true
  )
))

Querying Analytics ​

There are four ways to read analytics back: the CLI (terminal-friendly, scriptable), the REST API (admin-only), a server function (server-side, scheduled), and the Admin Console (visual).

From the CLI ​

All commands support --json for machine-readable output. Most accept a --window-days flag.

bash
# DAU / WAU / MAU + growth (default 28-day window)
primitive analytics overview
primitive analytics overview --window-days 28 --json

# Active-user series
primitive analytics daily-active --window-days 28
primitive analytics rolling-active --window-days 7

# Cohort retention (no window flag — full matrix)
primitive analytics cohort-retention

# Top users
primitive analytics top-users --window-days 7 --limit 20

# Search and per-user
primitive analytics user-search --query user@example.com

# Who joined the app on a UTC day, or across a range of up to 90 days.
# Rows carry signedUpAt / signupDay; page with --offset while the answer
# reports truncated.
primitive analytics user-search --signup-day 2026-09-06
primitive analytics user-search --signup-start-day 2026-09-01 --signup-end-day 2026-09-06
primitive analytics user-search --signup-day 2026-09-06 --limit 100 --offset 100
primitive analytics user-detail <user-ulid>
primitive analytics user-snapshot <user-ulid>

# Raw event feed
primitive analytics events --window-days 7 --page 0

# Group by: action | feature | route | country | deviceType | plan | day
primitive analytics events-grouped --group-by feature --window-days 14

# Error groups: failures grouped by fingerprint, with per-day counts
primitive analytics errors-groups --window-days 7 --status-class 5xx

# ...with a raw sample of each group and the run it came from
primitive analytics errors-groups --window-days 7 --verbose

# Prompt / integration analytics
primitive analytics prompts --limit 5
primitive analytics integrations

analytics prompts ranks prompts by executions and shows MEDIAN, P95, TOKENS, COST and AVG COST per prompt. COST is what that prompt's costed executions spent over the window and AVG COST is the average over those executions alone — so a prompt whose runs reported no price shows - in both rather than $0.00, and a window only some of whose runs reported one carries an (n/m) marker beside the total. --json adds executionsWithCost, totalCost and avgCost to each row, the last two null when nothing in the window reported a cost.

Per-subject analytics live under the top-level analytics noun — analytics prompts and analytics integrations are the homes for them, so there is one command per question and no per-noun duplicate to choose between. Function invocations are ordinary events: analytics events-grouped --group-by action counts function.invoke beside everything else, and analytics events lists them.

errors-groups collapses failures into stable fingerprints — messages that differ only in ids, numbers, URLs, or timestamps group together, while the structure that distinguishes them survives: the keys of a JSON error body, quoted SCREAMING_SNAKE error enums (UNAVAILABLE, QUOTA_EXCEEDED) and a status under a code / status key are all preserved, so two upstream errors of the same shape stay separate groups. Anything that varies per request — including a quoted value in prose and an uppercase request id — is replaced, so one error never spreads across a group per value. Each row carries a representative title, the source and bounded statusClass (4xx / 5xx / transport), a total over the window, a per-day count series, the first_seen / last_seen range, and an exemplar: a raw sample of one failure. Add --verbose to print the exemplar under each row.

Filter with --status-class and --source. source says which surface failed: a workflow run (workflow_run), a workflow step (workflow_step), an integration call (integration), a server-function invocation (function_invocation) or a server-function task run (function_run). A function's group is scoped by its function key, and a task run's exemplar carries the run_id you pass to primitive functions logs --run. statusClass is only ever set for an integration call and for a message that embeds an HTTP status, so filtering a function group by it will miss most of them.

From the REST API ​

Every CLI query above is backed by an admin-only REST endpoint under /app/{appId}/api/analytics/... — use the CLI's --json output to discover the shapes, or call the endpoints directly from your own tooling.

The event, grouped-event and error-group endpoints take at most 10 filters per request. An eleventh is a 400 carrying code: "FILTER_LIMIT_EXCEEDED" and the message Too many filters: 11. Maximum is 10. — rather than a narrower result set, so a query is never quietly answered with fewer filters than you asked for. Match on the code: the message interpolates the submitted count, so it can't be compared literally. The same limit applies to ctx.api.analytics from a function and to the Admin Console's filter bar, which stops accepting chips at ten.

Two CLI views are shaped for the terminal rather than mirroring an endpoint. analytics events --json is a log view, so the CLI normalizes it into the shared inspection item shape — { items, page, pageSize, totalRows }, with each row projected to the operator-facing fields — rather than printing the endpoint's own { rows, page_size, total_rows }; see Inspecting and debugging. And analytics overview --json calls the four separate DAU, WAU, MAU, and growth endpoints and prints them as one { dau, wau, mau, growth } object. Call the endpoints directly if you want their own shapes.

From a Server Function ​

A server function reads analytics through ctx.api.analytics, on the app's own authority — the simplest way to ship a recurring digest, an admin email, or a Slack post that summarizes activity, cron-fired with a trigger declared on the function itself.

From the Admin Console ​

The Analytics section of the Admin Console shows usage metrics, daily/weekly/monthly active users, and per-user breakdowns visually. Use it for ad-hoc exploration; use the CLI or a server function when you need scripted or recurring queries.

Reading query results ​

Every query returns a JSON object, and for the same query the REST response body and a function's result are the same shape — apart from the two CLI views noted above, --json prints that same shape too. A few properties of the results are worth knowing before you compute anything from them.

previous is the window before the one you asked for. The DAU, WAU, and MAU results pair a value with a previous covering the immediately preceding, non-overlapping window, so a month-over-month comparison is a single query rather than two. Both windows start on a UTC calendar-day boundary, but the current one ends at query time — it carries a partial final day where previous is whole days — so expect deltaPct to read low early in the UTC day, most obviously for DAU, where "today so far" is compared against all of yesterday.

deltaPct is a fraction, not a percentage. 0.25 means +25%. When previous is 0 there is no meaningful ratio and the field reports a fixed sentinel (1, or 0 when value is 0 too) — show "no baseline" rather than a number.

A top-users row carries two "first seen" values. firstSeen is the user's first recorded event across the app's whole retained history — it does not move when you change --window-days, so a firstSeen inside the last day is a genuinely new user. firstSeenInWindow is that user's first event inside the window you asked for, and it moves with the window by definition, as the rest of the row (eventCount, lastSeen) does. Derive "new signups" from firstSeen — or from the growth query's new_users, which counts the same thing — never from firstSeenInWindow, which over a short window marks every returning user as a new signup, day after day. Both are bounded by retention: a user whose first event has aged out of the dataset reads as first seen at the oldest event still retained.

An error group's exemplar can be null. It carries a raw sample of one failure in the group, and there isn't always one to carry: a very noisy group can consume the sampling budget and leave a quieter one without one. Fall back to normalized_title rather than treating the null as an error.

Error-group day buckets are sparse. Each errors-groups row carries a per-day count series that has no entry for a day the error didn't fire. Divide by the window length rather than by the number of buckets when you compute a trailing per-day baseline; dividing by the buckets averages over only the days the error occurred, which makes a rare error look like an everyday one and keeps a spike below your threshold. The daily active-user series behaves the opposite way: it always carries one row per day in the window, zero-filled.

Best Practices ​

  1. Use verb_noun action names — photo_uploaded, report_generated, settings_changed.
  2. Group events with feature — set consistently to enable per-feature dashboards (gallery, settings, billing).
  3. Keep context_json small — truncated to 1 KiB. Don't include full payloads.
  4. Don't log per-frame telemetry — design around meaningful actions, not continuous telemetry.
  5. Use setPlanOverride / setAppVersionOverride instead of passing plan / app_version on every call.
  6. Lock down anything that surfaces analytics — it reads aggregate data; keep access = "hasRole('admin')" on a function that calls it.

Next Steps ​

Documentation validated against js-bao-wss-client 3.4.0 · js-bao 0.11.0 · primitive-admin 1.0.62 · primitive-app 3.1.0 — 2026-09-30