Platform

Analytics

Track events, screens, users, crashes and timings from your apps, then read funnels, retention, performance and web traffic in Studio or over the API.

Every OrbitNest project has a built-in product analytics pipeline. Send events from the JavaScript, Node.js or Flutter SDK, or over REST, then explore them in the Studio Analytics tab or query them through the API and the MCP server.

How it works

  1. Your app sends batches of events to POST /api/analytics/events.
  2. OrbitNest validates each event, adds the visitor's country, region and city from the IP address, and stores it in the analytics store.
  3. Crash, identify and performance_metric events also feed crash records, identity links and timing percentiles.
  4. Studio, the query API and the MCP tools read the stored data. Studio's SDK tabs update live as new events arrive.

Nothing is tracked automatically. Every event, screen view, identify call and crash comes from a call in your code.

Authenticate ingestion

The ingestion endpoint accepts any of these credentials:

  • Ingestion token: a write-only token scoped to one project.
  • Project API key: the project's current anon or service-role key. A key replaced by Regenerate Keys is rejected. The JavaScript, Node.js and Flutter SDKs send the key automatically.
  • End-user access token: a signed-in user's access token for the project. Refresh tokens are refused.

The endpoint tries every credential the request carries, in this order, and the first valid one decides the project: x-analytics-token, apikey, x-api-key, then Authorization: Bearer. An invalid user token sent next to a valid key or ingestion token doesn't cause a 401. The same rules apply to GET /api/analytics/projects/:projectId/sdk-config.

An ingestion token can only send events. It cannot read analytics or touch your database. Deactivate or revoke it to stop accepting events from a leaked build without rotating your API keys.

Ingestion tokens

In Studio, open the project's Analytics tab and select Tokens:

  1. Enter an optional name, then create the token.
  2. Copy it right away. It is shown only once and only a hash is stored.
  3. Use the Active/Inactive toggle to pause a token, or Revoke to delete it for good.

Or manage tokens over the API with your Studio admin JWT:

Terminal
# Create (returns { "token": "<64 hex chars>", "hint": "..." })
curl -X POST "https://api.orbitnest.io/api/analytics/projects/$PROJECT_ID/tokens" \
  -H "Authorization: Bearer $ORBITNEST_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "ios-prod" }'

# List (id, name, is_active, last_used_at, created_at; never the token itself)
curl "https://api.orbitnest.io/api/analytics/projects/$PROJECT_ID/tokens" \
  -H "Authorization: Bearer $ORBITNEST_ADMIN_TOKEN"

# Deactivate or reactivate
curl -X PATCH "https://api.orbitnest.io/api/analytics/projects/$PROJECT_ID/tokens/$TOKEN_ID" \
  -H "Authorization: Bearer $ORBITNEST_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "is_active": false }'

# Revoke permanently (204)
curl -X DELETE "https://api.orbitnest.io/api/analytics/projects/$PROJECT_ID/tokens/$TOKEN_ID" \
  -H "Authorization: Bearer $ORBITNEST_ADMIN_TOKEN"

$PROJECT_ID is the project UUID from the Studio URL (/projects/<id>/...). The caller must belong to the workspace that owns the project.

Track events with the SDKs

All three SDKs expose the same analytics client:

MethodSends
identify(userId, traits)An identify event with the traits as properties. It also attaches user_id to this and every later event from the client
track(event, properties)A custom event with any name
screen(screenName, properties)A screen_view event with properties.screen set to screenName
crash(error, properties)A crash event with the error's type, message and stack in error_type, error_message and stack_trace
flush()Sends the queued events now

Events are queued and sent in batches. A batch goes out when 50 events are queued, or 5 seconds after the first queued event, whichever comes first. The SDKs don't flush when a page unloads or the app is killed, so call flush() before a known exit.

JavaScript (browser)

analytics.ts
import { createClient, localStorageAdapter } from '@orbitneststudio/js';

const orbit = createClient({
  apiKey: import.meta.env.VITE_ORBITNEST_ANON_KEY,
  storage: localStorageAdapter,
  // analyticsToken: import.meta.env.VITE_ORBITNEST_ANALYTICS_TOKEN, // optional
});

// Page views: screen() emits `screen_view`, which powers top screens and web analytics
orbit.analytics.screen(location.pathname, { referrer: document.referrer });

// Custom events
orbit.analytics.track('signup_started', { plan: 'pro' });

// After the user signs in
orbit.analytics.identify('user_123', { plan: 'pro' });

// Send now instead of waiting for the next batch
const { data, error } = await orbit.analytics.flush();
// data: { accepted, rejected, errors }

In a browser, the anonymous ID is stored in localStorage under orbitnest_anonymous_id, so a returning visitor keeps the same ID. The session ID (orbitnest_session_id) rotates after 30 minutes without an event. Without localStorage (private mode or SSR), both IDs are random for each client instance. The event platform is web in a browser and node elsewhere.

Node.js

server.ts
import { createClient } from '@orbitneststudio/node';

const client = createClient({
  apiKey: process.env.ORBITNEST_SERVICE_KEY!,
  analyticsToken: process.env.ORBITNEST_ANALYTICS_TOKEN, // optional
});

client.analytics.identify('user_123');
client.analytics.track('invoice_paid', { amount: 29, currency: 'USD' });

// Flush before the process exits or a serverless handler returns
await client.analytics.flush();

On a server, the anonymous ID and session ID are random for each client instance.

One identity per client

identify() sets the user for every later event from that client instance, and there is no reset method. On a server that handles many users, don't share one client's analytics for per-user events. Send them over REST with an explicit user_id instead.

Flutter

main.dart
import 'package:orbitnest_studio_flutter/orbitnest_studio_flutter.dart';

Future<void> main() async {
  await EnvConfig.initialize();
  final orbitnest = OrbitNestClient.create();

  orbitnest.analytics.screen('Home');
  orbitnest.analytics.track('checkout_completed', properties: {
    'plan': 'pro',
    'amount': 29,
  });

  // After the user signs in
  orbitnest.analytics.identify('user_123', traits: {'plan': 'pro'});

  await orbitnest.analytics.flush();
}

The Flutter client creates a new anonymous ID and session ID each time the client is created (normally once per app launch), with platform set to flutter. Failed batches are dropped silently so analytics never crashes your app. While a user is signed in, the Flutter SDK sends the user's access token on analytics requests, which the ingestion endpoint accepts.

Report crashes with useful grouping

The server reads these crash properties, taking the first non-empty one in each row:

FieldRead from
Stackstack_trace, stackTrace, stack
Messageerror_message, errorMessage, message, error
Typeerror_type, errorType, else the prefix of the stack or message, such as TypeError in TypeError: x is undefined or FormatException in FormatException: …, else UnknownError

Crashes are grouped by a fingerprint of the first two lines of the stack. A crash without a stack is grouped by its type and message. The current SDK crash() helpers send error_type, error_message and stack_trace; older versions sent error and stack, which the server also reads. Properties you pass override the helper's fields, for example to set your own type:

TypeScript
try {
  await checkout();
} catch (err) {
  orbit.analytics.crash(err as Error, { error_type: 'CheckoutError' });
}

Report timings

There is no dedicated timing helper. Track an event named performance_metric:

TypeScript
orbit.analytics.track('performance_metric', {
  metric_name: 'checkout_load', // grouping key; defaults to "custom"
  value_ms: 412,
  label: 'web',                 // optional
  endpoint: '/api/cart',        // optional, for request timings
  http_method: 'GET',           // optional
  http_status: 200,             // optional
});

Send events over REST

Terminal
curl -X POST https://api.orbitnest.io/api/analytics/events \
  -H "x-analytics-token: $ORBITNEST_ANALYTICS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {
        "event_id": "0b6f3c1e-8d2a-4f7e-9a51-2c1d7f9e4b10",
        "event": "checkout_completed",
        "anonymous_id": "device-7f3a",
        "user_id": "user_123",
        "session_id": "sess-42",
        "platform": "web",
        "timestamp": "2026-10-10T12:00:00Z",
        "properties": { "plan": "pro", "amount": 29 }
      }
    ]
  }'
JSON
{ "accepted": 1, "rejected": 0, "errors": [] }

Event fields

FieldRequiredDescription
event_idYesUnique ID (1–128 chars), such as a UUID
eventYesEvent name (1–100 chars). Any name is allowed
anonymous_idYesStable device or browser ID (1–256 chars)
platformYesUp to 20 chars: flutter, web, node, ios, android, react-native or unknown
timestampYesISO 8601 client time
user_idNoYour user ID (up to 256 chars). Set it on every event after sign-in
session_idNoSession ID (up to 128 chars). Sessions are built from it
idempotency_keyNoDeduplication key (up to 128 chars). Defaults to event_id
app_idNoUp to 64 chars
app_version, sdk_versionNoUp to 30 chars each
propertiesNoObject with any custom data
device_infoNomodel, os, os_version, brand, is_tablet, locale, timezone, screen_width, screen_height
ipNoThe visitor's IP, when a server relays the event. Used only for the geo lookup and never stored

Limits and errors

  • Up to 500 events per request.
  • A field that fails validation, or any field not in the table, fails the whole request with 400. Put custom data in properties.
  • An invalid credential returns 401.
  • Ingestion is rate limited to 1,000 requests per 60 seconds per IP address. Over that, requests return 429. Batch events instead of sending one per request.
  • Events can be rejected one at a time and returned in errors:
    • invalid_timestamp: more than 30 days in the past, or more than 24 hours in the future.
    • duplicate: the idempotency_key (or event_id) was recently seen for this project.

Event names with special handling

EventEffect
screen_viewCounted in top screens, and as a pageview in web analytics. Read properties.screen (then screen_name, then path) for the screen label and properties.referrer for the traffic source
page_viewCounted in top screens and session screen counts, but not as a web analytics pageview. Prefer screen_view
identifyRecords a link from anonymous_id to user_id
crashCreates a crash record, grouped by fingerprint (see Report crashes)
performance_metricAdds a timing sample (see Report timings)

Every other name is a custom event.

Analytics in Studio

Open a project and select Analytics. The view menu has these tabs:

TabShows
Overview, Performance, Database, Auth, FunctionsActivity charts built from the project's logs: request volume, success rate, table access, sign-ins and function calls
Web AnalyticsVisitors, pageviews, referrers, devices and countries, with a 7, 30 or 90 day range
SDK EventsOverview metrics, events over time, top events and top screens for the last 30 days
SDK Perfp50, p90, p95 and p99 for each performance_metric name
CrashesCrash groups by frequency, with affected users and first and last seen times
TokensCreate, pause and revoke ingestion tokens

Funnels and retention are available through the API and the MCP server, not in Studio.

The log-based tabs and SDK Events work on every plan. Web Analytics, SDK Perf and Crashes read advanced analytics endpoints, which need the Starter or Pro plan.

Query API

Query endpoints live under https://api.orbitnest.io/api/analytics/projects/:projectId and take your Studio admin JWT. Most accept these query parameters:

ParameterDescription
from, toDates (YYYY-MM-DD, or ISO timestamps truncated to the date). Default: the last 30 days, ending today
granularityhour, day (default), week (weeks start Monday) or month. Used by the timeseries endpoints
platformExact platform value, such as web or flutter
app_versionExact app_version value
geo_countryExact country value. Crash data has no country, so /crashes and the crash count in /overview ignore it
limit1–10000. Used by the top-N endpoints
offsetRows to skip, for paging the top-N endpoints. Not used by /web/devices

platform, app_version and geo_country apply to every endpoint except /funnel.

EndpointReturns
GET /overviewHeadline metrics, including DAU and MAU
GET /events/timeseries{ bucket, event, total } per bucket and event
GET /events/top{ event, total, unique_users }, default 20 rows
GET /screens/top{ screen, views, unique_users }, default 20 rows
POST /funnelFunnel step counts
GET /retentionDay-N retention for one cohort day
GET /performanceTiming percentiles per metric
GET /crashesCrash groups, default 20 rows
GET /web/overviewWeb headline metrics
GET /web/timeseries{ bucket, pageviews, visitors, sessions }
GET /web/referrers{ referrer, pageviews, visitors }, default 20 rows
GET /web/devices{ os, platform, pageviews, visitors }, up to 50 rows
GET /web/geo{ country, pageviews, visitors }, default 30 rows

Except in /overview and /web/overview, counts are 64-bit integers and come back as JSON strings, such as "total": "1520".

unique_users and the user counts below include identified users only: events with a non-empty user_id. Call identify() after sign-in, or anonymous traffic won't show up in these numbers.

Overview, DAU and MAU

Terminal
curl "https://api.orbitnest.io/api/analytics/projects/$PROJECT_ID/overview?from=2026-09-10&to=2026-10-10&platform=web" \
  -H "Authorization: Bearer $ORBITNEST_ADMIN_TOKEN"
JSON
{
  "total_events": 48210,
  "total_sessions": 3120,
  "avg_duration_s": 214.5,
  "dau": 182,
  "mau": 1405,
  "total_crashes": 7
}
  • total_events, total_sessions and total_crashes cover from through to. avg_duration_s is the mean time from a session's first event to its last.
  • DAU is the number of distinct identified users with at least one event on the to date.
  • MAU is the number of distinct identified users over the 30 days ending on to, whatever from is.
  • platform and app_version filter every number, including crashes. geo_country filters everything except crashes.

Top events and screens

/events/top ranks event names by count. /screens/top ranks screen_view and page_view events by screen label, taken from properties.screen, then properties.screen_name, then properties.path. Events without a label are left out. The SDK screen() helpers set properties.screen.

Funnels

Send the ordered event names in the body:

Terminal
curl -X POST "https://api.orbitnest.io/api/analytics/projects/$PROJECT_ID/funnel" \
  -H "Authorization: Bearer $ORBITNEST_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "steps": ["signup_started", "signup_completed", "checkout_completed"],
    "from": "2026-10-01",
    "to": "2026-10-10"
  }'
JSON
[
  { "level": 3, "users": "41" },
  { "level": 2, "users": "88" },
  { "level": 1, "users": "230" },
  { "level": 0, "users": "512" }
]
  • steps: 1–20 event names. Each is up to 120 chars of letters, digits, _, ., -, : and spaces.
  • from and to default to the last 7 days.
  • Each identified user with events in the range gets a level: how many steps they completed in order, with all of those steps within 7 days of their first step. level: 0 means they never hit the first step.
  • To get the users who reached step k, add up the users of every row with level >= k. In the example, 230 + 88 + 41 = 359 users started the funnel and 41 completed it.

Retention

Terminal
curl "https://api.orbitnest.io/api/analytics/projects/$PROJECT_ID/retention?from=2026-09-10" \
  -H "Authorization: Bearer $ORBITNEST_ADMIN_TOKEN"
JSON
[
  { "day_n": "0", "retained_users": "120" },
  { "day_n": "1", "retained_users": "54" },
  { "day_n": "7", "retained_users": "31" }
]

The cohort is every identified user with at least one event on the from date (default: 30 days ago). It includes returning users, not only new ones. Each row counts how many of them had an event day_n days later, for days 0 through 30. Days with no returning users are left out. to is ignored.

Performance

/performance returns one row per metric_name from your performance_metric events: samples, avg_ms, p50_ms, p90_ms, p95_ms and p99_ms.

Crashes

/crashes returns crash groups ordered by frequency: fingerprint, error_type, error_message, occurrences, affected_users, first_seen and last_seen.

Web analytics

/web/overview returns:

FieldDefinition
pageviewsNumber of screen_view events
visitorsDistinct anonymous_id values
sessionsDistinct session_id values
bounce_ratePercentage of sessions with at most one screen_view
pages_per_sessionAverage screen_view events per session
avg_session_secondsAverage time from a session's first event to its last
new_visitorsVisitors active in the range whose first event ever is on or after from
returning_visitorsVisitors active in the range who were first seen before from

Breakdowns:

  • Referrers come from properties.referrer on screen_view events. Views without one are grouped as Direct. Pass document.referrer when you call screen().
  • Devices group by device_info.os and platform. The SDKs don't send device_info, so OS shows as Unknown unless you send events over REST with device_info.
  • Countries come from the request IP, or from the event's ip field when a server relays it.

Analytics from the MCP server

The MCP server exposes these tools. Each uses the active project unless you pass a projectId.

ToolDoes
orbitnest_get_analytics_overviewOverview metrics (from, to, platform)
orbitnest_get_analytics_eventsEvent timeseries (from, to, granularity, platform)
orbitnest_get_top_analytics_eventsTop events (from, to, limit)
orbitnest_get_top_analytics_screensTop screens (from, to, limit)
orbitnest_get_analytics_funnelFunnel (steps, at least 2; from, to)
orbitnest_get_analytics_retentionRetention (from)
orbitnest_get_analytics_performanceTiming percentiles (from, to)
orbitnest_get_analytics_crashesCrash groups (from, to)
orbitnest_create_analytics_tokenCreates an ingestion token (name)
orbitnest_list_analytics_tokensLists ingestion tokens
orbitnest_revoke_analytics_tokenDeletes a token (tokenId, confirmRevocation: true)

Data retention and privacy

Analytics data expires automatically:

DataKept for
Events and sessions2 years from the event time
Crash records and timing samples1 year from the event time

The client IP is used only to look up country, region and city. The raw IP is never stored.

Delete a user's analytics data

To honor an erasure request, submit a delete request for the user_id:

Terminal
curl -X POST "https://api.orbitnest.io/api/analytics/projects/$PROJECT_ID/gdpr/requests" \
  -H "Authorization: Bearer $ORBITNEST_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "user_id": "user_123", "type": "delete" }'

The request returns 202 and runs in the background. It finds every anonymous_id linked to the user, then:

  • deletes the user's events, matched by user_id or a linked anonymous_id
  • clears user_id and anonymous_id on their crash records, sessions and timing samples
  • removes the user's identity links

Event counts drop by the deleted events. Crash, session and timing counts stay the same, but nothing links them to the user anymore. The deletion is processed asynchronously by the analytics store, so it can take a while to finish on large projects.

The response includes a request_id. Your Studio account must belong to the project's workspace to submit or list requests.

Export a user's analytics data

To answer an access request, submit an export request for the user_id:

Terminal
curl -X POST "https://api.orbitnest.io/api/analytics/projects/$PROJECT_ID/gdpr/requests" \
  -H "Authorization: Bearer $ORBITNEST_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "user_id": "user_123", "type": "export" }'
# → 202 { "message": "GDPR request accepted", "request_id": "…", "type": "export", "user_id": "user_123" }

The export runs in the background. It collects the user's events, sessions, crash records and timing samples, matched by user_id or a linked anonymous_id, into one JSON file:

JSON
{
  "project_id": "…",
  "user_id": "user_123",
  "anonymous_ids": ["anon_abc"],
  "generated_at": "2026-10-13T10:00:00.000Z",
  "row_cap_per_table": 50000,
  "truncated": { "raw_events": false, "sessions": false, "crashes": false, "performance_metrics": false },
  "raw_events": [],
  "sessions": [],
  "crashes": [],
  "performance_metrics": []
}

Each table is capped at 50,000 rows. truncated is true for a table that had more. If the file is larger than 45 MB, the request fails. The file is stored in the project's private, platform-reserved _orbitnest_gdpr_exports bucket and counts toward your storage quota. SDK keys and user tokens get 403 on _orbitnest* buckets.

Check the status with the list endpoint. It returns the 50 most recent requests:

Terminal
curl "https://api.orbitnest.io/api/analytics/projects/$PROJECT_ID/gdpr/requests" \
  -H "Authorization: Bearer $ORBITNEST_ADMIN_TOKEN"

status goes pending → processing → completed or failed. A failed request has the reason in error. A completed export has a result_url. Download it with your admin token:

Terminal
curl -o export.json "https://api.orbitnest.io/api/analytics/projects/$PROJECT_ID/gdpr/requests/$REQUEST_ID/download" \
  -H "Authorization: Bearer $ORBITNEST_ADMIN_TOKEN"

The result_url is not public. It needs a Studio token for an account in the project's workspace. Exports can be downloaded for 7 days after they complete. After that the download returns 410 and deletes the file; submit a new request. Studio has no screen for GDPR requests yet, so use the API.

Plans

Ingestion, ingestion tokens, sdk-config and erasure requests work on every plan, so data is collected on Free and is there after an upgrade. The query API is split by your workspace plan:

TierEndpointsPlans
Basicoverview, events/timeseries, events/top, screens/topEvery plan, including Free
Advancedretention, funnel, performance, crashes, web/overview, web/timeseries, web/referrers, web/devices, web/geoStarter and Pro

On Free, advanced endpoints return 403:

JSON
{
  "statusCode": 403,
  "error": "Forbidden",
  "code": "PLAN_REQUIRED",
  "required_plan": "starter",
  "plan": "free",
  "message": "This feature requires the Starter plan or higher. Upgrade at /settings/billing."
}

The MCP analytics tools call the same endpoints, so the same split applies to them. See Billing.