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
- Your app sends batches of events to
POST /api/analytics/events. - OrbitNest validates each event, adds the visitor's country, region and city from the IP address, and stores it in the analytics store.
- Crash,
identifyandperformance_metricevents also feed crash records, identity links and timing percentiles. - 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:
- Enter an optional name, then create the token.
- Copy it right away. It is shown only once and only a hash is stored.
- 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:
# 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:
| Method | Sends |
|---|---|
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)
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
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
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:
| Field | Read from |
|---|---|
| Stack | stack_trace, stackTrace, stack |
| Message | error_message, errorMessage, message, error |
| Type | error_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:
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:
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
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 }
}
]
}'{ "accepted": 1, "rejected": 0, "errors": [] }Event fields
| Field | Required | Description |
|---|---|---|
event_id | Yes | Unique ID (1–128 chars), such as a UUID |
event | Yes | Event name (1–100 chars). Any name is allowed |
anonymous_id | Yes | Stable device or browser ID (1–256 chars) |
platform | Yes | Up to 20 chars: flutter, web, node, ios, android, react-native or unknown |
timestamp | Yes | ISO 8601 client time |
user_id | No | Your user ID (up to 256 chars). Set it on every event after sign-in |
session_id | No | Session ID (up to 128 chars). Sessions are built from it |
idempotency_key | No | Deduplication key (up to 128 chars). Defaults to event_id |
app_id | No | Up to 64 chars |
app_version, sdk_version | No | Up to 30 chars each |
properties | No | Object with any custom data |
device_info | No | model, os, os_version, brand, is_tablet, locale, timezone, screen_width, screen_height |
ip | No | The 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 inproperties. - 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: theidempotency_key(orevent_id) was recently seen for this project.
Event names with special handling
| Event | Effect |
|---|---|
screen_view | Counted 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_view | Counted in top screens and session screen counts, but not as a web analytics pageview. Prefer screen_view |
identify | Records a link from anonymous_id to user_id |
crash | Creates a crash record, grouped by fingerprint (see Report crashes) |
performance_metric | Adds 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:
| Tab | Shows |
|---|---|
| Overview, Performance, Database, Auth, Functions | Activity charts built from the project's logs: request volume, success rate, table access, sign-ins and function calls |
| Web Analytics | Visitors, pageviews, referrers, devices and countries, with a 7, 30 or 90 day range |
| SDK Events | Overview metrics, events over time, top events and top screens for the last 30 days |
| SDK Perf | p50, p90, p95 and p99 for each performance_metric name |
| Crashes | Crash groups by frequency, with affected users and first and last seen times |
| Tokens | Create, 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:
| Parameter | Description |
|---|---|
from, to | Dates (YYYY-MM-DD, or ISO timestamps truncated to the date). Default: the last 30 days, ending today |
granularity | hour, day (default), week (weeks start Monday) or month. Used by the timeseries endpoints |
platform | Exact platform value, such as web or flutter |
app_version | Exact app_version value |
geo_country | Exact country value. Crash data has no country, so /crashes and the crash count in /overview ignore it |
limit | 1–10000. Used by the top-N endpoints |
offset | Rows 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.
| Endpoint | Returns |
|---|---|
GET /overview | Headline 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 /funnel | Funnel step counts |
GET /retention | Day-N retention for one cohort day |
GET /performance | Timing percentiles per metric |
GET /crashes | Crash groups, default 20 rows |
GET /web/overview | Web 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
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"{
"total_events": 48210,
"total_sessions": 3120,
"avg_duration_s": 214.5,
"dau": 182,
"mau": 1405,
"total_crashes": 7
}total_events,total_sessionsandtotal_crashescoverfromthroughto.avg_duration_sis 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
todate. - MAU is the number of distinct identified users over the 30 days ending on
to, whateverfromis. platformandapp_versionfilter every number, including crashes.geo_countryfilters 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:
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"
}'[
{ "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.fromandtodefault 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: 0means they never hit the first step. - To get the users who reached step k, add up the
usersof every row withlevel >= k. In the example, 230 + 88 + 41 = 359 users started the funnel and 41 completed it.
Retention
curl "https://api.orbitnest.io/api/analytics/projects/$PROJECT_ID/retention?from=2026-09-10" \
-H "Authorization: Bearer $ORBITNEST_ADMIN_TOKEN"[
{ "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:
| Field | Definition |
|---|---|
pageviews | Number of screen_view events |
visitors | Distinct anonymous_id values |
sessions | Distinct session_id values |
bounce_rate | Percentage of sessions with at most one screen_view |
pages_per_session | Average screen_view events per session |
avg_session_seconds | Average time from a session's first event to its last |
new_visitors | Visitors active in the range whose first event ever is on or after from |
returning_visitors | Visitors active in the range who were first seen before from |
Breakdowns:
- Referrers come from
properties.referreronscreen_viewevents. Views without one are grouped asDirect. Passdocument.referrerwhen you callscreen(). - Devices group by
device_info.osandplatform. The SDKs don't senddevice_info, so OS shows asUnknownunless you send events over REST withdevice_info. - Countries come from the request IP, or from the event's
ipfield 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.
| Tool | Does |
|---|---|
orbitnest_get_analytics_overview | Overview metrics (from, to, platform) |
orbitnest_get_analytics_events | Event timeseries (from, to, granularity, platform) |
orbitnest_get_top_analytics_events | Top events (from, to, limit) |
orbitnest_get_top_analytics_screens | Top screens (from, to, limit) |
orbitnest_get_analytics_funnel | Funnel (steps, at least 2; from, to) |
orbitnest_get_analytics_retention | Retention (from) |
orbitnest_get_analytics_performance | Timing percentiles (from, to) |
orbitnest_get_analytics_crashes | Crash groups (from, to) |
orbitnest_create_analytics_token | Creates an ingestion token (name) |
orbitnest_list_analytics_tokens | Lists ingestion tokens |
orbitnest_revoke_analytics_token | Deletes a token (tokenId, confirmRevocation: true) |
Data retention and privacy
Analytics data expires automatically:
| Data | Kept for |
|---|---|
| Events and sessions | 2 years from the event time |
| Crash records and timing samples | 1 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:
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_idor a linkedanonymous_id - clears
user_idandanonymous_idon 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:
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:
{
"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:
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:
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:
| Tier | Endpoints | Plans |
|---|---|---|
| Basic | overview, events/timeseries, events/top, screens/top | Every plan, including Free |
| Advanced | retention, funnel, performance, crashes, web/overview, web/timeseries, web/referrers, web/devices, web/geo | Starter and Pro |
On Free, advanced endpoints return 403:
{
"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.