# Observability protocol

# Observability protocol

This is the wire format the [observability](./observability.html) module ingests. It is deliberately small and fully documented so that a language without a first-party SDK is a copy-paste away rather than a support ticket.

The canonical definition lives in `packages/core/src/observe/envelope.ts`. This page describes exactly that schema.

## Endpoint

```
POST /api/ingest/v1/<publicKey>
Content-Type: application/json
```

`publicKey` is the `ink_pub_…` credential from your project's DSN. It is public by design.

- CORS is open (`Access-Control-Allow-Origin: *`); `OPTIONS` is handled.
- Send **no credentials** — the endpoint ignores cookies, and sending them breaks the wildcard CORS response.
- Success is `202 Accepted` with `{ "accepted": n, "dropped": n }`.

### Response codes

| Code | Meaning | Should a client retry? |
| --- | --- | --- |
| `202` | Accepted (`dropped > 0` explains what was filtered and why) | No |
| `400` | Malformed JSON or schema violation — the body names the field | No, it will never succeed |
| `404` | Unknown project key | No |
| `413` | Body over 1 MB | No, send smaller batches |
| `429` | Rate limited | Yes, with backoff |
| `5xx` | Server-side failure | Yes, with backoff |

## The envelope

```json
{
  "sdk": { "name": "inline-observe.javascript", "version": "0.1.0" },
  "sentAt": "2026-08-15T10:32:00.000Z",
  "environment": "production",
  "release": "1.4.0",
  "releaseCommit": "9f2c1ab…",
  "platform": "javascript",
  "events": [ /* 1–100 events */ ]
}
```

Only `events` is required. `environment`, `release`, `releaseCommit` and `platform` at the envelope level are **defaults** applied to any event that omits them — so a batching client sends them once.

Three shorthand forms are accepted, for the smallest possible integration:

```jsonc
{ "events": [ { … } ] }   // canonical
[ { … }, { … } ]          // a bare array of events
{ "message": "boom" }     // a single bare event
```

### snake_case is accepted

Every key is normalized, so `event_id`, `release_commit`, `in_app` and `context_line` parse identically to their camelCase spellings. `line_no` and `line` normalize to `lineno`; `col_no` and `column` to `colno`; `abs_path` to `filename`. Write idiomatic Python or Go without a translation layer.

The keys **inside** `tags`, `extra`, `contexts`, `data` and `headers` are your data, not protocol, and are preserved verbatim.

## An event

| Field | Type | Notes |
| --- | --- | --- |
| `eventId` | string | Your id, for support lookups. Optional. |
| `timestamp` | ISO 8601 | Defaults to receipt time. Rejected if more than 7 days from now — a device with a wrong clock would otherwise pin the issue to 2044 and float it to the top of every list. |
| `type` | `error` \| `message` | Inferred: `error` when `exception` is present, else `message`. |
| `level` | `fatal` \| `error` \| `warning` \| `info` \| `debug` | Defaults to `error` for exceptions, `info` for messages. Below the project's floor ⇒ dropped. |
| `platform` | `javascript`, `node`, `python`, `dotnet`, `go`, `ruby`, `java`, `php`, `rust`, `other` | Display only. |
| `message` | string | Required if there's no `exception`. |
| `transaction` | string | Route, job or component name — e.g. `POST /checkout`. Used as the culprit when no frame is in-app, and as the grouping key for stackless events. |
| `environment` | string | e.g. `production`, `staging`. |
| `release` | string | Must match what you upload source maps under. |
| `releaseCommit` | string | The commit this build came from. **This is what points the root-cause agent at the right code.** |
| `serverName` | string | Instance/host identifier. |
| `exception` | object | See below. |
| `breadcrumbs` | array | Up to 200, oldest → newest. |
| `tags` | `{string: string}` | Filterable key/values. |
| `contexts` | object | Free-form: `browser`, `os`, `runtime`, `device`. |
| `extra` | object | Anything else. |
| `user` | object | `id`, `email`, `username`, `ipAddress`. Hashed server-side into a counting key. |
| `request` | object | `url`, `method`, `query`, `headers`. |
| `fingerprint` | string[] | Grouping override. Wins over everything. |

### `exception`

```json
{
  "exception": {
    "values": [{
      "type": "TypeError",
      "value": "Cannot read properties of undefined (reading 'id')",
      "mechanism": { "type": "onunhandledrejection", "handled": false },
      "stacktrace": { "frames": [ /* … */ ] }
    }]
  }
}
```

`values` is an array so chained/caused-by exceptions have somewhere to live; the first entry is the one that groups and titles the issue.

### Frames — newest first

> **This is the one place inline differs from what you may expect.** Sentry orders frames oldest-first, with the crash at the bottom. inline orders them **newest first**: `frames[0]` is where the error was thrown. Every reader — human or model — wants the crash site first, and it's also the order every JS engine already produces.

| Field | Type | Notes |
| --- | --- | --- |
| `filename` | string | Absolute path or URL. |
| `function` | string | Function or method name. |
| `module` | string | Logical module, if your runtime has one. |
| `lineno`, `colno` | number | 1-indexed line; column is needed for accurate source-map lookup on minified bundles. |
| `inApp` | boolean | **Set this.** It decides grouping and what the AI reads first. `true` = your code, `false` = dependency/runtime. Inferred if omitted (paths containing `node_modules`, `.next`, `node:internal` etc. are treated as vendor). |
| `contextLine` | string | The source line, if your runtime can read it. |
| `preContext`, `postContext` | string[] | Up to 10 lines either side. |

Server runtimes can usually fill in the context lines directly; for minified browser code inline recovers them from your uploaded source maps.

### Breadcrumbs

```json
{
  "timestamp": "2026-08-15T10:31:58.220Z",
  "type": "http",
  "category": "fetch",
  "level": "warning",
  "message": "POST /api/checkout → 500",
  "data": { "status": 500, "durationMs": 812 }
}
```

Ordered oldest → newest, ending at the error. In practice this is the fastest route to a reproduction — the culprit is more often the failed request two rows up than the frame that happened to throw.

## Limits

| Limit | Value |
| --- | --- |
| Request body | 1 MB |
| Events per envelope | 100 |
| Frames per exception | 100 (deeper tails truncated) |
| Breadcrumbs per event | 100 (newest kept) |
| Rate limit | 120 requests/minute per project per IP |
| Field lengths | Bounded per field by the schema; overlong values are rejected, not silently cut |

## Source-map upload

```
POST /api/observe/artifacts?release=<version>&name=<url-path>&commitSha=<sha>&environment=<env>
Authorization: Bearer <write key>
Content-Type: application/json

<the raw .map file as the body>
```

Metadata in the query string, raw bytes as the body — so `curl --data-binary @file` works and no multipart parsing is needed anywhere.

`name` is the URL path of the **bundle** (not the map), by convention prefixed with `~`: `~/_next/static/chunks/page-ab12.js`. inline reduces it to a match key the same way it reduces incoming stack-frame URLs, which is how the two meet.

## Worked example

```json
{
  "sdk": { "name": "python-manual", "version": "1.0.0" },
  "environment": "production",
  "release": "2026.08.15",
  "release_commit": "9f2c1ab7d4e",
  "events": [{
    "level": "error",
    "platform": "python",
    "transaction": "POST /api/claims",
    "exception": {
      "values": [{
        "type": "KeyError",
        "value": "'member_id'",
        "stacktrace": {
          "frames": [
            { "filename": "app/claims.py", "function": "submit", "line_no": 88, "in_app": true },
            { "filename": "app/routes.py", "function": "handle", "line_no": 42, "in_app": true },
            { "filename": "site-packages/flask/app.py", "function": "dispatch", "line_no": 1502, "in_app": false }
          ]
        }
      }]
    },
    "breadcrumbs": [
      { "category": "db", "message": "SELECT member WHERE id=… → 0 rows", "level": "warning" }
    ],
    "tags": { "tenant": "acme" },
    "user": { "id": "u_8123" }
  }]
}
```

Reported this way, the issue groups on `KeyError` + `submit`/`handle`, shows the breadcrumb that explains it, counts one affected user — and if the project is linked to a repo, the root-cause agent opens `app/claims.py` at commit `9f2c1ab7d4e` and reads line 88.
