# Platforms

One project, one key, one inbox. Web, iOS, React Native, Android, Flutter and server
failures all POST to `POST /v1/ingest`. The `platform` field is what lets the console
tell them apart — and the fingerprint deliberately ignores it, so the same failure
seen on two platforms stays one issue.

## The vocabulary

Seven values, closed set (`packages/api/src/bugs/platform.ts`):

| Value | Label | Typical source |
|-------|-------|----------------|
| `web` | Web | `@bugtape/sdk` in a browser |
| `ios` | iOS | [BugTapeCore alpha](./swift.md) |
| `android` | Android | your own POST, or an OkHttp user agent |
| `react-native` | React Native | [`docs/examples/react-native/bugtape.ts`](./examples/react-native/bugtape.ts) |
| `flutter` | Flutter | your own POST, or a Dart user agent |
| `server` | Server | [`docs/examples/server/curl.md`](./examples/server/curl.md) |
| `other` | Other | anything else (desktop, Electron, Tauri, unknown) |

Common aliases are normalised, case-insensitively, with `_`/space folded to `-`:
`iPhone`, `ipad`, `swift`, `swiftui`, `uikit`, `macos` → `ios`; `expo`, `rn`,
`reactnative` → `react-native`; `browser`, `js`, `spa`, `pwa` → `web`; `node`, `bun`,
`deno`, `python`, `rails`, `go`, `lambda`, `worker`, `cron`, `cli` → `server`;
`dart` → `flutter`; `electron`, `tauri`, `desktop`, `unknown` → `other`. An unrecognised
string becomes `other` rather than being rejected.

## How the platform is decided

Three steps, first hit wins:

1. **The explicit `platform` field** on the ingest payload. Always send it if you can.
2. **`metadata.platform`** — the legacy location, still honoured.
3. **The user agent**, in this order:

   | User agent contains | Platform |
   |---------------------|----------|
   | `Mozilla/` | `web` |
   | `CFNetwork`, `Darwin/`, `iOS`, `iPhone`, `iPad` | `ios` |
   | `Dalvik`, `okhttp`, `Android` | `android` |
   | `Flutter`, `Dart/` | `flutter` |
   | `node`, `undici`, `axios`, `curl`, `python`, `Go-http-client`, `Java/`, `Ruby`, `PHP`, `Wget`, `Deno`, `Bun/` | `server` |
   | *(no user agent at all)* | `server` |
   | anything else | `other` |

A server-side hook that sends no user agent is therefore `server` by default, which is
almost always what you want.

## What each platform captures

| | Web | iOS | React Native | Server |
|---|---|---|---|---|
| Uncaught errors | yes | no fatal native capture | JS errors only, via `ErrorUtils` | you POST them |
| Unhandled rejections | yes | n/a | yes, via Hermes tracker | n/a |
| Handled errors | `capture` | `client.capture(error, screen:fields:)` | `BugTape.capture(err, { screen })` | one POST each |
| Breadcrumbs / console | full console capture | last 100 by default, explicit hooks | last 100, manual | put them in `events` |
| DOM replay | yes (rrweb) | no | no | no |
| Network waterfall | yes (fetch, XHR, resource timing) | no | no | no |
| Rage clicks | yes | no | no | no |
| Device / OS | browser + OS parsed from UA | `utsname` model + OS version | `Platform.OS`/`Version`/`Model` | whatever you send |
| Release / user | yes | yes | yes | yes |

The legacy mobile examples are deliberately thin: no replay, no network interception, no
symbolication, no native module. The iOS drop-in captures handled errors and manual
messages only. Fatal native crash capture is unavailable pending a qualified native
SDK; it installs no signal or uncaught-exception handlers. React Native JS error
capture does not capture fatal native crashes. See each example's README for details.

The current [Swift core package](./swift.md) adds consent controls, a durable bounded offline queue and managed installation delivery receipts. Swift on macOS sends `platform: other` and `osFamily: macos`; iOS sends `ios`/`ios`. Neither provides native replay or fatal recording.

## Field mapping

| Ingest field | Web | Non-web | Shows up as |
|--------------|-----|---------|-------------|
| `platform` | `web` | `ios` / `react-native` / `server` / … | Platform facet + row tags |
| `url` | the real page URL | **the screen name**: `app://Checkout`, `worker://billing/charge` | Fingerprint input, and the "where" on the occurrence |
| `title` | `TypeError: …` | must be shaped `<Token>Error: <message>` | Issue title, and the error-kind fingerprint input |
| `release` | build sha / deploy id | app version or build (`1.4.0 (218)`) | Regression detection, "last release" per user |
| `environment` | `production` / `staging` | same | Triage filter |
| `userId` | `identify()` | `identify()` | **Affected users**, user timeline |
| `reporterEmail` | reporter | `identify(email:)` | User attribution when there is no `userId` |
| `sessionId` | browser session | one UUID per app launch | Occurrence grouping |
| `device` | parsed from UA | `iPhone15,3`, `Pixel 8` | Occurrence metadata + Impact breakdown |
| `osVersion` | parsed from UA | `17.4.1` | Occurrence metadata + Impact breakdown |
| `metadata` | anything | bundle id, locale, your `extra` | Occurrence detail |
| `events` | DOM, network, console, errors | breadcrumbs + one `error` event | Timeline on the bug detail |

### The one rule non-web clients must not break

The fingerprint is `errorType + normalizedMessage + urlPattern`, where `errorType` is
read off a `^(\w+Error):` title prefix and `urlPattern` comes from the `url`.

A native client that sends `title: "crash"` and no `url` gives every failure in the app
the same fingerprint — one issue, thousands of occurrences, useless. So:

- Shape every title `<Token>Error: <message>` (`NSURLError: …`, `TimeoutError: …`).
- Send a meaningful `url`: `app://<ScreenName>` for apps, `worker://<service>/<path>`
  for server jobs.

Both example clients do this for you. Pass `screen:` on every `capture` call.

## How the console shows it

- **Issues** — a **Platform** select filters the list to one platform. Each row carries
  a small text tag per platform seen; an issue hit on web and iOS shows both.
- **Bug detail** — a **Platform** fact row lists every platform for that issue. The
  **Impact** tab breaks occurrences down by platform, browser and OS.
- **Affected users** (bug detail) — one row per end user, with a **Platforms** column
  and their last release.
- **End user page / user timeline** — every occurrence one person hit across every bug,
  with a **Platform** column per row.
- **Setup** — the snippet tabs (npm, Script tag, Next.js, iOS, React Native,
  Server / curl) each carry a platform, and **Send console sample**
  (`POST /v1/projects/:id/test-event`) mints a real occurrence tagged with the tab you
  picked, through the normal pipeline, before you install anything. Console samples
  and aggregate report counts do not verify client installation.

## How agents see it

Over MCP (see [agents.md](./agents.md)):

- `list_bug_groups { platform }` — grouped triage, filtered to groups with an occurrence
  on that platform. Each group returns a `platforms` array.
- `get_bug_users { bugId }` — one row per affected end user, with `platforms`,
  `lastRelease` and `lastOccurrenceId`.
- `get_user_timeline { userKey }` — everything one user hit across bugs and projects,
  with `platform`, `release` and `environment` on each occurrence.

Use the platform filter to find groups with iOS reports. Groups may also contain
other platforms; this filter does not mean "iOS-only" or prove fatal crash capture.

## Design rule

**Platform is never part of the fingerprint.**

The same error on web and iOS is **one issue** with `platforms: ['ios', 'web']`. Splitting
by platform would double every cross-platform bug in the inbox and hide the fact that a
shared API change broke both clients. Filter by platform when you want to; the grouping
does not change underneath you.

## Where to go next

- [Swift core alpha](./swift.md) — package download, consent, handled reports and managed setup
- [`examples/react-native/`](./examples/react-native/) — one TypeScript file, Expo and bare RN
- [`examples/server/curl.md`](./examples/server/curl.md) — curl, Node and Python recipes
- [`ingest-schema.md`](./ingest-schema.md) — every field, limit and error response
- [`agents.md`](./agents.md) — how your coding agent hears about new bugs
