Mobile and desktop apps
One Analisa app, one site key: your website, your iOS and Android apps and your desktop app (Electron, Tauri or native) report to the same place, and every report can be filtered by platform.
What is available today
- Web: the
@racoelho/analyticsSDK (script tag or npm). Its events are taggedplatform: webautomatically. - Any app, right now: the HTTP ingestion protocol below. One
POSTwith JSON — it works from Swift, Kotlin, Dart, React Native, Electron or a backend. - React Native / Expo: the
@racoelho/analytics-react-nativeSDK implements this protocol for you: screens (React Navigation and Expo Router), sessions, app lifecycle, crashes, offline queue and retries. Install instructions are in Install → React Native in the app dashboard. - Electron / Tauri: the
@racoelho/analytics-desktopSDK does the same for desktop apps (Windows, macOS and Linux): screens, sessions, app lifecycle, crashes and offline queue. Install instructions are in Install → Electron/Tauri. - Android (Kotlin): the
io.useanalisa:analyticsSDK covers screens (Jetpack Compose Navigation or Activities), sessions, app lifecycle, crashes and offline queue. Install instructions are in Install → Android (Kotlin). - Flutter: the
analisa_analyticspackage covers iOS, Android, Windows, macOS and Linux from Dart: screens (NavigatorObserver), sessions, app lifecycle, crashes and offline queue. Install instructions are in Install → Flutter. - iOS / Swift (coming soon): a native Swift SDK that does the same. Until then, use the HTTP protocol (or Flutter / React Native).
Turn on app ingestion
- Open Apps → your app → Settings in the dashboard.
- Under Mobile and desktop apps, tick Receive events from apps (iOS, Android, desktop).
- Optional: list your bundle ids / package names (e.g.
com.yourcompany.app). When the list is not empty, only requests carrying one of them (headerX-Analisa-App-Idorcontext.app.id) are accepted. - Send events with your app's
site_keyand noOriginheader. Web traffic keeps using the allowed-origins check.
What you see in the dashboard
- Platform filter next to the period picker (All, Web, iOS, Android, Windows, macOS, Linux): it applies to every report and is remembered as you navigate.
- Platforms card on the dashboard with sessions and visitors per platform, shown when more than one platform has data.
- App versions: users, sessions, errors and crash-free sessions per version over time, plus how many users are already on the latest version.
- Pages and screens: apps record screens as page views with a route-like path (
/product/detail), so Pages, Page flow, Funnels and Goals work for apps too. - Devices: OS version and device model for app traffic.
- Errors: crashes and errors grouped like on the web, with platform and app version, filterable by version.
About app keys and abuse
A site key inside a mobile or desktop binary is public: anyone can extract it. App ingestion and the app-id allow list are spam mitigation of the same strength as the Origin check on the web — they stop accidental reuse and drive-by junk, not a determined attacker. Never ship your secret key in an app; it is for server-side calls only.
Ingestion protocol v1 (specification)
The full contract SDKs implement: endpoint, payload, context fields, event catalog (screens, lifecycle, crashes, identify), identity and sessions, batching and retries.
This is the wire contract between any Analisa SDK (web, React Native/Expo, Electron/Tauri, Flutter, Swift, Kotlin, or your own HTTP client) and the ingestor. One app in the dashboard has one site key, and that same key receives events from the website and from the mobile and desktop apps. The dashboard then filters and groups everything by platform.
The key words MUST, SHOULD and MAY are used as in RFC 2119.
- Status: v1, stable. New optional fields may be added; nothing here will change meaning.
1. Endpoint
POST {endpoint}/v1/i{endpoint} defaults to https://function-bun-production-3339.up.railway.app, so the full default URL is https://function-bun-production-3339.up.railway.app/v1/i. SDKs MUST let the developer override the full URL (self-hosting, proxies).
There is a single endpoint for every event type. There is no separate identify, screen or batch endpoint.
2. Request
2.1 Headers
| Header | Required | Notes |
|---|---|---|
Content-Type: application/json | yes | text/plain is also accepted (the web SDK uses it to avoid a CORS preflight). Body MUST be UTF-8 JSON either way. |
User-Agent | recommended | Free form, e.g. ShopApp/2.3.0 (iOS 17.4; iPhone15,2) analisa-swift/0.1.0. Parsed only as a fallback when context lacks a field. |
X-Analisa-App-Id | conditional | Bundle id / package name. Required when the app restricts allowed_app_ids and the events do not carry context.app.id (see §4). Sending it always is fine. |
Accept-Language | optional | Used as locale fallback. |
X-Country | optional | ISO 3166-1 alpha-2 guess from the device locale/region. The server prefers its own geo headers. |
Authorization: Bearer <secret_key> | server-side only | Never put the secret key in a mobile or desktop binary. Bearer requests are for backends (see §4.3). |
Origin | never set it manually | Browsers set it. Native clients MUST NOT send one (see §4). |
No cookies are used or set.
2.2 Body
{
"site_key": "pk_live_abc123",
"sent_at": 1767225600123,
"context": { "...": "context shared by every event in the batch (§3)" },
"batch": [ { "...": "event (§2.3)" } ]
}| Field | Type | Required | Notes |
|---|---|---|---|
site_key | string | yes (unless Bearer) | The app's public key from the dashboard. |
sent_at | integer ms epoch | recommended | Device clock at the moment the request is sent (set it on every attempt, including retries). If it differs from the server clock by more than 60 s, the server shifts every event ts in the batch by the difference, so a device with a wrong clock still produces correct times. |
context | object | recommended | Batch-level context (§3). Applies to every event; an event's own context overrides it key by key (top-level keys: an event context.app replaces the whole batch context.app). |
batch | array | yes | 1 to 1000 events. |
2.3 Event
{
"id": "7d1f8a0e-4b5c-4c1e-9d0b-2f3a4b5c6d7e",
"event": "page_view",
"ts": 1767225599000,
"anon_id": "c0a8012e-5f4b-4d4e-8f7a-1b2c3d4e5f60",
"session_id": "0f9e8d7c-6b5a-4f3e-8d2c-1b0a9f8e7d6c",
"user_id": "user_123",
"path": "/product/detail",
"url": "shopapp://product/42",
"referrer": "",
"props": { "screen_name": "ProductDetail", "product_id": "42" },
"context": { "...": "optional per-event overrides (§3)" }
}| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes for native SDKs | Client-generated unique id, 8–128 chars of [A-Za-z0-9._:-] (UUID v4 recommended). It is the idempotency key: the same id sent twice for the same site key is stored once. Generate it when the event is created, persist it with the queued event and reuse it on every retry. |
event | string | yes | Event name, max 128 chars. Reserved names in §5. Use snake_case. |
ts | integer ms epoch | yes | When the event happened on the device (not when it was sent). Numbers only (a numeric string is tolerated). Events more than 10 min in the future (after the sent_at correction) are stored with the server time. |
anon_id | string | yes | Install-level anonymous id (§6.1). |
session_id | string | yes | Session id (§6.2). |
user_id | string | null | when known | Your app's user id after identify (§6.3). Omit or null when anonymous. |
path | string | for page_view | Route-like path. For apps, the screen path (§5.1). Query string and trailing slash are stripped. |
url | string | optional | Full URL (web) or deep link (apps). |
referrer | string | optional | Web referrer. Apps MAY send the install/deep-link source (e.g. https://google.com, play-store); leave empty otherwise. |
utm | object | optional | { source, medium, campaign, term, content } (strings). Apps MAY fill it from campaign deep links. |
props | object | optional | Event properties. Flat JSON values (string, number, boolean, null) recommended; nested values are stored but not filterable in the dashboard. Keep it under ~4 KB per event. |
context | object | optional | Per-event context overrides (§3). |
Legacy web fields (lang, tz, viewport, screen, ua_string) are still accepted; native SDKs use context instead.
2.4 Limits
| Limit | Value | Server response when exceeded |
|---|---|---|
| Request body | 100 KB (MAX_PAYLOAD_BYTES) | 413 {"ok":false,"error":"payload_too_large"} |
| Events per request | 1000 | 413 {"ok":false,"error":"batch_too_large","max":1000} |
SDKs SHOULD send at most 50 events per request and split larger queues.
3. Context
context describes where the event came from. Every field is optional; send everything you can read cheaply. The server validates and normalizes it: unknown keys are dropped, enum values are lower-cased and checked, strings are trimmed and truncated, numbers out of range are dropped. A bad field never rejects the event.
{
"platform": "ios",
"app": { "version": "2.3.0", "build": "451", "id": "com.shop.app" },
"os": { "name": "iOS", "version": "17.4.1" },
"device": { "model": "iPhone15,2", "manufacturer": "Apple", "type": "phone" },
"locale": "pt-BR",
"tz": "America/Sao_Paulo",
"screen": { "w": 393, "h": 852, "density": 3 },
"sdk": { "name": "analisa-swift", "version": "0.1.0" },
"framework": "swift"
}| Field | Type / values | Max | Source examples |
|---|---|---|---|
platform | web | ios | android | windows | macos | linux | other | enum | iPadOS → ios; Android TV → android; Electron/Tauri → the OS they run on. Aliases accepted: darwin/osx → macos, win32 → windows. Unknown values become other. |
app.version | string | 64 | CFBundleShortVersionString, versionName, app.getVersion(), package_info_plus.version |
app.build | string (numbers accepted) | 64 | CFBundleVersion, versionCode, buildNumber |
app.id | string | 255 | Bundle id / package name / Electron appId. Used by allowed_app_ids (§4). |
os.name | string | 64 | UIDevice.systemName, "Android", "Windows", "macOS", distro name on Linux |
os.version | string | 64 | "17.4.1", "14", "10.0.22631", "14.5" |
device.model | string | 128 | Machine identifier: utsname.machine (iPhone15,2), Build.MODEL (SM-S918B), hw.model (MacBookPro18,3) |
device.manufacturer | string | 128 | Apple, Build.MANUFACTURER (samsung) |
device.type | phone | tablet | desktop | tv | other | enum | iPad / Android smallestScreenWidthDp >= 600 → tablet; Electron/Tauri → desktop |
locale | BCP 47 string | 35 | pt-BR, en-US |
tz | IANA zone | 64 | TimeZone.current.identifier, ZoneId.systemDefault() |
screen.w, screen.h | number 0–100000 | Logical size in points/dp (not physical pixels), portrait or current orientation | |
screen.density | number 0–20 | UIScreen.scale, displayMetrics.density, devicePixelRatio | |
sdk.name | string | 64 | analisa-react-native, analisa-flutter, analisa-swift, analisa-kotlin, analisa-electron, analisa-tauri, @racoelho/analytics (web) |
sdk.version | string | 32 | SDK semver |
framework | lower-case slug | 32 | web, react-native, expo, flutter, electron, tauri, swift, swiftui, kotlin, compose |
How the server derives what it stores:
- No
context.platform: browser requests (allowed Origin) and server requests (Bearer) areweb— this keeps every existing web integration working unchanged. Native requests (no Origin) without a platform are stored asother. Native SDKs MUST always sendplatform. - The web SDK sends
{"platform":"web","sdk":{...},"framework":"web"}as batch context. - For native events the stored OS name is canonicalized to the same names the web UA parser uses (
iOS,Android,Windows,macOS,Linux), so web and app traffic group together in the Devices report. The User-Agent header is only a fallback for missing fields.
4. Authentication and abuse control
| Client | How it authenticates |
|---|---|
| Browser (web SDK) | site_key + the browser's Origin, checked against the app's allowed origins (empty list = any origin). |
| Mobile / desktop app | site_key + no Origin header. Accepted only when the app has "Receive events from apps" (allow_native) turned on in the dashboard settings. If the app also lists allowed app ids (allowed_app_ids), the request MUST carry an app id — X-Analisa-App-Id header and/or context.app.id (batch or event) — and every app id in the request must be in that list. |
Desktop webview with a custom scheme (tauri://localhost, app://, file://, null) or Tauri's Windows WebView2 host (http(s)://tauri.localhost) | Treated as a native request (rule above). Any other webview served from http(s):// is a web origin: add it to allowed origins, or send from the main process. |
| Your backend | Authorization: Bearer <secret_key> (no site_key needed). Never ship the secret key in an app. |
Be honest about what this protects. A site key inside a mobile or desktop binary is public: anyone can extract it and the app id, and replay requests without an Origin. allow_native and allowed_app_ids are spam mitigation of the same strength as the Origin check on the web (which curl also bypasses): they stop drive-by junk and other apps reusing your key by accident, not a determined attacker. Keep allow_native off for apps that are web-only.
Error responses (JSON, ok: false):
| Status | error | Meaning | SDK action |
|---|---|---|---|
| 400 | invalid_payload | Body is not JSON or batch is missing/empty | Drop the batch (it will never succeed), log in debug |
| 400 | site_key_required | No site_key and no Bearer | Drop, disable sending (misconfiguration) |
| 401 | unauthorized | Bad Bearer secret | Drop, disable sending |
| 402 | quota_exceeded / subscription_inactive | Plan limit | Drop the batch; pause sending for 1 h |
| 403 | native_not_allowed | No Origin and allow_native is off | Drop, disable sending for this process; log a clear message ("enable 'Receive events from apps' in the dashboard") |
| 403 | app_id_required / app_id_not_allowed | allowed_app_ids check failed | Drop, disable sending; log |
| 403 | origin_not_allowed | Web origin not allowed | Drop |
| 403 | site_inactive | App paused/disabled | Drop, disable sending |
| 404 | site_not_found | Unknown site key | Drop, disable sending |
| 413 | payload_too_large / batch_too_large | See §2.4 | Split the batch in half and resend; a single event that is still too large is dropped |
| 429, 5xx, network error, timeout | — | Transient | Keep the batch and retry with backoff (§7) |
Success: 200 {"ok":true,"received":N}. N can be lower than the batch size (duplicates removed); that is still success — remove the whole batch from the queue.
5. Event catalog
All names below are reserved. Anything else is a custom event (track(name, props)).
5.1 Screen views — page_view
Apps record screens as page_view so Pages ("Pages and screens"), Page flow, Funnels and Goals work the same for web and apps:
{ "id": "…", "event": "page_view", "ts": 1767225599000, "anon_id": "…", "session_id": "…",
"path": "/product/detail", "props": { "screen_name": "ProductDetail" } }pathMUST be route-like: starts with/, lower-case,-between words, no ids (/product/detail, not/product/42). If the app has a router with paths (Expo Router, React Navigation linking, go_router, Compose Navigation routes), send the route pattern; otherwise derive it from the screen name:ProductDetail→/product-detail.props.screen_nameSHOULD carry the human screen name.- Put ids in
props(product_id), never inpath, to keep the Pages report aggregated. - Send one
page_viewper screen appearance. Do not resend when the same screen re-renders or when the app returns from background to the same screen.
Shortcut: {"event":"screen_view","screen_name":"ProductDetail"} (or props.screen / props.screen_name) is accepted and stored as a page_view with path derived as above when path is absent. New SDKs SHOULD send page_view directly.
5.2 Lifecycle (apps)
| Event | When | props |
|---|---|---|
app_open | Cold start (process launch), and when the app comes to the foreground and a new session starts (§6.2) | launch: "cold" | "warm"; first_launch: true only on the very first launch after install (otherwise omit or false) |
app_foreground | Every transition background → foreground | background_ms: time spent in background |
app_background | Every transition foreground → background | foreground_ms: time since the last foreground/open |
app_update | First launch after app.version or app.build changed (send it before that launch's app_open) | previous_version, previous_build |
Order on a cold start after an update: app_update → app_open {launch:"cold"} → first page_view. On the first ever launch: app_open {launch:"cold", first_launch:true} (no app_update). Desktop SDKs map "background" to all windows hidden/minimized (or app deactivated on macOS) and MAY skip app_foreground/app_background when that is not observable.
5.3 Errors and crashes — error
Same shape as the web SDK, so the Errors report groups them:
{ "event": "error", "props": {
"error_type": "NSInvalidArgumentException",
"error_message": "unrecognized selector sent to instance",
"error_stack": "0 CoreFoundation …\n1 libobjc.A.dylib …",
"error_kind": "crash",
"error_handled": false,
"error_fingerprint": "a1b2c3d4"
} }| Prop | Notes |
|---|---|
error_type | Exception class / signal (SIGSEGV, java.lang.NullPointerException, TypeError) |
error_message | Message, max ~1000 chars |
error_stack | Stack trace, max ~8 KB; strip absolute file-system paths and user data |
error_kind | crash (process died: uncaught exception or fatal signal), anr (Android not responding), unhandled (uncaught but app survived, e.g. JS/Dart error handler), handled (captureException) |
error_handled | true only for handled |
error_fingerprint | Stable group id: hash of error_type + the first 3 in-app frames without line numbers/addresses, so the same bug groups across builds. Max 64 chars. |
Crashes are usually sent on the next launch: persist the crash report to disk in the crash handler (do no network I/O there), and on the next launch enqueue it with the crash's own ts, session_id and context (the version that crashed), before app_open. The dashboard computes crash-free sessions per app version from error_kind = "crash".
5.4 Identify — identify
{ "id": "…", "event": "identify", "user_id": "user_123",
"props": { "email": "ana@shop.com", "plan": "pro", "id_prop": "email" } }user_id(top level) is required.propsare traits;id_prop(optional) names the trait the dashboard uses as the person's label.- After
identify, every following event MUST carry the sameuser_id(persist it across launches) untilreset(). reset()(logout): clearuser_id, start a new session and keepanon_id.- Do not send
identifyagain for the sameuser_idwithin 5 minutes.
5.5 Custom events
Any other name: { "event": "add_to_cart", "props": { "product_id": "42", "price": 99.9 } }. The web SDK's helpers (purchase, signup, login, search, …) define recommended names and props; native SDKs SHOULD expose the same helpers with the same names.
6. Identity and sessions
6.1 anon_id
- UUID v4, generated on first launch, persisted in app storage (UserDefaults, SharedPreferences / DataStore,
localStoragein Electron/Tauri, a file in the app data dir). - Never derived from hardware ids, advertising ids (IDFA/GAID) or anything that survives reinstall. A reinstall produces a new
anon_id— that is intended. - Sent on every event.
6.2 session_id
- UUID v4. A session ends after 30 minutes without events.
- Before stamping any event, the SDK checks
now - last_event_at > 30 min(wall clock); if so (or if there is no session yet) it creates a newsession_id.last_event_atis updated for every event and persisted together withsession_id, so a process restart within 30 minutes continues the same session. - Background → foreground: if the app was in background for more than 30 minutes, the next event starts a new session, and the SDK emits
app_open {launch:"warm"}as its first event. Otherwise it emits onlyapp_foregroundand the session continues. reset()always starts a new session.- Lifecycle events count as activity (an
app_backgroundupdateslast_event_at).
6.3 user_id
Set by identify, persisted, cleared by reset() (§5.4).
6.4 Consent and opt-out
SDKs MUST offer setConsent('denied') / optOut() that stops collection, clears the queue and the persisted user_id, and survives restarts. Respect platform tracking settings where they apply. Do not collect IDFA/GAID, IP-derived precise location, contacts or any personal data by default.
7. Delivery: queue, batching, retry
- Persistent queue. Events are appended to an on-disk queue (file, SQLite, MMKV,
localStorage…) so they survive app kills. Cap it at 1000 events; when full, drop the oldest. Drop events older than 7 days at send time. - Flush triggers. Flush when the queue reaches 20 events, every 10 s while the app is in the foreground, on
app_background(iOS: insidebeginBackgroundTask; Android: fromProcessLifecycleOwnerON_STOP, using WorkManager if the send cannot finish), and at launch for anything left from the previous run. Only one request in flight at a time. - Batch. Up to 50 events per request (§2.4). Set
sent_aton every attempt. - Retry. On network error, timeout (use 10 s), 429 or 5xx: keep the batch, retry with exponential backoff
min(2^attempt × 1 s, 5 min)with ±20 % jitter. HonorRetry-Afterwhen present. Reset the backoff after a success. Because every event has anid, retrying a batch that was in fact stored is harmless. - Do not retry other 4xx (§4 table). On 413, split the batch.
- Offline. Do not attempt sends while the OS reports no connectivity; flush when it returns.
- Never block the UI thread and never throw into the host app.
8. Privacy guarantees the server applies
- The client IP is used only for country lookup and stored anonymized (last IPv4 octet / last 80 bits of IPv6 zeroed). Do not send IPs in props.
- No fingerprinting: the server never combines IP/UA into an identifier.
- Delete requests (GDPR) work by
anon_id/user_idthrough the dashboard.
9. Example: plain HTTP
curl -X POST https://function-bun-production-3339.up.railway.app/v1/i \
-H 'Content-Type: application/json' \
-H 'User-Agent: ShopApp/2.3.0 (Android 14; SM-S918B)' \
-H 'X-Analisa-App-Id: com.shop.app' \
-d '{
"site_key": "pk_live_abc123",
"sent_at": 1767225600123,
"context": {
"platform": "android",
"app": { "version": "2.3.0", "build": "451", "id": "com.shop.app" },
"os": { "name": "Android", "version": "14" },
"device": { "model": "SM-S918B", "manufacturer": "samsung", "type": "phone" },
"locale": "pt-BR", "tz": "America/Sao_Paulo",
"screen": { "w": 384, "h": 854, "density": 2.8125 },
"sdk": { "name": "curl", "version": "1" }, "framework": "kotlin"
},
"batch": [
{ "id": "b6f1c9a4-0d0e-4f57-9a3b-6a5f3f7f2b10", "event": "app_open", "ts": 1767225599000,
"anon_id": "c0a8012e-5f4b-4d4e-8f7a-1b2c3d4e5f60", "session_id": "0f9e8d7c-6b5a-4f3e-8d2c-1b0a9f8e7d6c",
"props": { "launch": "cold" } },
{ "id": "0b8e7c4f-2a6d-4c51-8f0b-9d3e2a1c5b77", "event": "page_view", "ts": 1767225599500,
"anon_id": "c0a8012e-5f4b-4d4e-8f7a-1b2c3d4e5f60", "session_id": "0f9e8d7c-6b5a-4f3e-8d2c-1b0a9f8e7d6c",
"path": "/home", "props": { "screen_name": "Home" } }
]
}'
# → {"ok":true,"received":2}10. Minimal contract for a new SDK (checklist)
init(siteKey, { endpoint?, debug? }); never throws, never blocks the UI thread.- Persist
anon_id; managesession_idwith the 30-minute rule and foreground handling (§6). - Build
contextonce per launch (platform,app,os,device,locale,tz,screen,sdk,framework) and send it as batch context; refreshlocale/tz/screenon change. - Every event:
id(UUID),event,ts,anon_id,session_id,user_idwhen identified. - API:
track(name, props),screen(name, props?)→page_view(§5.1), `identify(userId, traits?, idProp?),reset(),captureException(error, props?),setConsent(...),flush()`. - Automatic:
app_open,app_update,app_foreground,app_background(§5.2) and crash capture (§5.3), each switchable off in the config. - Persistent queue, batching, backoff and the response handling table (§4, §7).
- No
Originheader, no secret key, no personal data by default. - Test against a real ingestor: events appear in the dashboard under the right platform, versions show in "App versions", a forced crash appears in Errors with
error_kind: "crash".