# Platforms

One project, one key, one inbox. Web, iOS, React Native, Android, Flutter and server
failures all go to `POST /v1/ingest`. The `platform` field lets the console tell them
apart. On the web and server path, the platform is not part of the fingerprint, so the
same failure can span platforms. Native reports include the platform (and the
application, build and code site) in their fingerprint, so a native crash does not
merge with a web report of the same title.

## The vocabulary

Seven values, a closed set:

| 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/evidence-floor.md`](./examples/server/evidence-floor.md) · [`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 not available: the drop-in 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 |

### Fingerprint by path

Web, server, and `other` use v1:

```
SHA256(errorType + ":" + normalizedMessage + ":" + urlPattern).slice(0, 16)
```

`errorType` is read off a `^(\w+Error):` title prefix. `urlPattern` comes from `url`.
A client that sends `title: "crash"` and no `url` gives every v1 failure the same
fingerprint. Shape titles `<Token>Error: <message>` and send a meaningful `url`
(`app://<ScreenName>`, `worker://<service>/<path>`).

Native (`ios` / `android` / `react-native` / `flutter`) uses v2. The fingerprint is
SHA256 of a canonical JSON `failureSignature` that includes `version`, `platform`,
`applicationId`, `buildId`, `signal`, and the symbol site or image offset. Title and
`url` are not that hash. Without application/build and a symbol site or image offset,
the capture isolates on its own id. A native crash does not merge with a web report of
the same title.

Server and React Native examples still shape title and `url` for the v1 path. Pass
`screen:` on every web-style `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.

## Grouping across platforms

**Web and server (v1): the platform is not in the fingerprint.** The same failure from
web and a worker stays one issue with two platforms.

**Native (v2): the platform is in the fingerprint.** A native crash and a web report with
the same title stay two issues. Filter by platform when you want to see one platform.

## Where to go next

- [Swift core alpha](./swift.md) — package download, consent, handled reports and managed setup
- [`examples/react-native/README.md`](./examples/react-native/README.md) — one TypeScript file, Expo and bare RN
- [`examples/server/evidence-floor.md`](./examples/server/evidence-floor.md) — required fields, MCP packet, Streamlit guidance
- [Python SDK](./python.md) — `pip install` package: uncaught exceptions, logging, Streamlit, redaction by default
- [Node.js SDK](./node.md) — `npm install` package: uncaught exceptions and rejections, Express, Next.js, redaction by default
- [`examples/server/curl.md`](./examples/server/curl.md) — curl, Node and Python recipes
- [`examples/server/bigquery-timeout.md`](./examples/server/bigquery-timeout.md) — redacting Python helper + BigQuery timeout
- [`privacy-proof-checklist.md`](./privacy-proof-checklist.md) — before-key DOM scrub proof
- [`ingest-schema.md`](./ingest-schema.md) — every field, limit and error response
- [`agents.md`](./agents.md) — how your coding agent hears about new bugs
