# Trace

Source: https://dabadata.com/docs/trace/ (HTML) - https://dabadata.com/docs/trace/index.md (this page as markdown)

dabadata keeps a trace: every mutation, every read through `/v1/db` and every live subscription writes one row to the trace: who did it, to which table, which rule applied, what was decided and why. When something surprises you, the answer is already written down.

## What is recorded

Trace rows are stored in Postgres and returned as JSON with camelCase keys:

| Field | Type | Meaning |
| --- | --- | --- |
| `id` | number | Increasing id. Use it to page backwards. |
| `at` | ISO string | When the call happened. |
| `source` | `http` \| `ws` \| `mcp` | How the call arrived. Subscriptions are `ws`. The MCP server talks HTTP, so its calls show `http` for now. |
| `user` | object | `{ id, role, name?, issuer? }`. The project key appears as `service`, a view token as the viewer, no token as `anonymous`. |
| `op` | string | `insert`, `update`, `delete`, `read` or `subscribe`; also, among others, `check` (a transaction's check), `action` (an [action](https://dabadata.com/docs/rules/index.md#actions-protecting-counters-capacity-and-stock)'s own row) and `ai` (an action's [model call](https://dabadata.com/docs/ai/index.md#trace)). |
| `table` | string | The table. |
| `rowId` | string \| null | The row, for mutations and single-row reads. |
| `decision` | `allowed` \| `denied` \| `bypass` | What the rules decided. |
| `rule` | Rule \| null | The rule that was applied, as JSON. |
| `explanation` | string \| null | The sentence naming the path through the rule. |
| `values` | object \| null | The values of a mutation. For an insert, after defaults and user ids were filled in. |
| `before` | object \| null | The row as it was when the rule looked at it (update, delete) - recorded even when the call was denied. |
| `after` | object \| null | The row after the change (insert, update). |
| `rowCount` | number \| null | How many rows a read or subscription returned. |
| `result` | `ok` \| `denied` \| `error` | How the call ended. |
| `error` | string \| null | The error message when `result` is `error`, e.g. a validation failure. |
| `durationMs` | number \| null | Time spent in the server. |
| `fanout` | number | How many live subscriptions received the change. |

Not traced: `/v1/health`, `/v1/schema`, token minting, and the simulator - a simulation changes nothing, so it leaves no row.

## An example row

Carol, a viewer, tried to delete a task:

```json
{
  "id": 39,
  "at": "2026-10-15T14:02:11.204Z",
  "source": "http",
  "user": { "id": "carol", "role": "viewer", "name": "Carol" },
  "op": "delete",
  "table": "tasks",
  "rowId": "5f1c2a9e-8b1d-4c2e-9a77-3d0e6b2f4a10",
  "decision": "denied",
  "rule": { "role": "admin" },
  "explanation": "delete tasks: denied, role \"viewer\" not in [\"admin\"]",
  "values": null,
  "before": {
    "id": "5f1c2a9e-8b1d-4c2e-9a77-3d0e6b2f4a10",
    "title": "Order chairs",
    "done": false,
    "owner": "bob",
    "due": null,
    "notes": null,
    "created_at": "2026-10-15T13:40:02.118Z",
    "updated_at": "2026-10-15T13:40:02.118Z",
    "created_by": "bob"
  },
  "after": null,
  "rowCount": null,
  "result": "denied",
  "error": null,
  "durationMs": 3,
  "fanout": 0
}
```

The same explanation came back to Carol in the 403 body, together with `"traceId": 39`, so a support question can start from the exact row.

## Fan-out

When a mutation succeeds, the server sends the change to every live subscription that may see the row, then records how many that was in `fanout`. Zero means nobody was listening (or nobody listening may read it). Reads and subscriptions always have `0`. It is the quickest way to answer "did the other screen get it?". See [Live subscriptions](https://dabadata.com/docs/live/index.md).

## Reading the trace

`GET /v1/trace` with the project key or a view token returns rows newest first:

```bash
curl "https://dabadata.com/v1/trace?limit=50&table=tasks&user=carol" \
  -H "Authorization: Bearer <project key>"
```

| Parameter | Default | Meaning |
| --- | --- | --- |
| `limit` | `200` | At most 1000. |
| `before` | none | Only rows with a smaller id. Pass the last id you have to get the next page. |
| `table` | none | Only this table. |
| `user` | none | Only this user id. |

To follow it live, open `/v1/live` with a view token: every new row arrives as `{ "type": "trace", "trace": { ... } }`.

A few habits that help when reading it:

- Filter by `user` first. Most questions are "why could this person not ...".
- A row with `result: "error"` and no `decision` never reached the rules: the request itself was wrong (an unknown column, a value of the wrong type, a missing row). `error` says what.
- Compare `before` and `after` to see exactly what an update changed.
- `bypass` rows were made with the project key or a view token. If you see more of them than you expect, find out who holds the key.

## Let your app's admins read it (trace.read)

The trace is the "recent activity" an admin page wants. Without a backend of your own, open it to some of your users with a rule at the top level of the definition, next to `tables`:

```json
"trace": { "read": { "role": "admin" } }
```

- `read` is a rule like a table's, checked against the caller (`owner` has no row here and is an error). A user token, or the publishable key alone (as `anonymous`), that passes it reads `GET /v1/trace` for its environment, with the same parameters, *values included*: whoever passes sees what every write wrote. Keep it to people who may.
- A caller the rule refuses gets 403 `forbidden` with the explanation. Without a `trace` block only the secret key, view tokens and the console read it (401 for anyone else), as before.
- Live: a `/v1/live` socket whose user passes the rule gets the `{ "type": "trace", "trace": { ... } }` messages too. The rule is checked for every message against the current definition, with the identity the socket opened with.

```ts
// The admin page's activity feed: the newest 50 writes.
const res = await fetch("https://dabadata.com/v1/trace?limit=50", {
  headers: { "X-Api-Key": "pk_live_EXAMPLEexample0000000000", Authorization: `Bearer ${db.token}` },
});
const { trace } = await res.json();
for (const t of trace.filter((t) => t.op !== "read" && t.op !== "subscribe")) {
  console.log(t.at, t.user?.id, t.op, t.table, t.result);
}
```

## The viewer's Trace page

Open the viewer at `/?token=<view token>` and choose **Trace**. It lists the newest calls first and prepends new ones as they happen, with a short highlight. You can narrow it by table, user and decision, and open any row to see the rule, the values, and the row before and after. The **Tables** page shows the rows themselves, live; **Rules** holds the matrix and the simulator.

## See it in the demo

The [live demo](https://dabadata.com/demo/) runs the shared task list in two panes, each as a different person, with the trace streaming underneath. Make Carol try to delete something and the denial, with its sentence, shows up below the moment she clicks.
