---
title: "Debug your app"
description: "Go from an error to a fix with local logs, error codes and mainsdk logs, then debug production with live tail, debug sessions, replay and triage. Explain, driver tracing and OpenTelemetry export are planned."
section: "guides"
status: "Building for 0.6"
spec: "debug-loop"
canonical: "https://mainsdk.dev/docs/guides/debugging"
updated: "2026-10-28"
---

# Debug your app

> Go from an error to a fix with local logs, error codes and mainsdk logs, then debug production with live tail, debug sessions, replay and triage. Explain, driver tracing and OpenTelemetry export are planned.

Status: Building for 0.6 · Canonical: https://mainsdk.dev/docs/guides/debugging · Updated: 2026-10-28

Find out why a request failed and fix it, using only local tools. Every error and log MainSDK writes carries a stable code, the file and line in your code, the `main.yaml` rule involved and a fix, so you or your coding agent can act on it directly.

**Before you start:** a MainSDK app that runs locally with `mainsdk dev`. Steps 1 to 4 are free and local; nothing leaves your machine.

> **Note:** Built today: `MainError` codes, `log`, `mainsdk logs`, `mainsdk check` and `mainsdk info`. Planned, not built: `mainsdk explain`, `MAINSDK_DEBUG=driver` and OpenTelemetry export. The MainSDK Cloud tools at the end are built. Steps below say where a planned tool would go.

## 1. Find the error in the local logs

By default records print to the terminal. To keep them for `mainsdk logs`, add a file sink once; it writes JSON lines to `.mainsdk/events/` (git-ignored). Passing sinks replaces the terminal output.

```ts title="server/index.ts"
import { configure, fileSink } from "mainsdk";

configure({ sinks: [fileSink(".mainsdk/events/app.jsonl")] });
```

```bash
npx mainsdk logs --level error
```

```text
2026-11-12T10:04:31.208Z ERROR This record belongs to another owner
```

To watch new events, or to see every record from one request (records carry `attributes.trace_id` inside a request with a trace):

```bash
npx mainsdk logs --follow
npx mainsdk logs --trace 4bf92f3577b34da6a3ce929d0e0e4736 --json
```

`--json` prints the stored record, which includes the `code` and `fix` of a `MainError`.

## 2. Look up the code

Find the code in the [error codes](https://mainsdk.dev/docs/reference/errors) reference for its cause and fix; the `fix` is also in the record from step 1. A problem response carries the same `code` and `fix` for your client. `mainsdk explain` (offline lookup by code or trace id) is planned.

```text
MAIN-DATA-0007 · This record belongs to another owner · 403
fix: Read orders through db.orders.for(session.user) so users only reach their own.
```

Your coding agent reads the same codes and fixes; see [Use MainSDK with your coding agent](https://mainsdk.dev/docs/getting-started/with-your-agent).

## 3. Fix the code

`MAIN-DATA-0007` means the owner scope did its job: the order route refused a record that belongs to someone else. The question is why the user asked for it. Follow the trace back to the list endpoint, which still reads orders through the escape hatch, so it returns every user's orders:

```ts title="app/api/orders/route.ts (before)"
import { auth } from "mainsdk";

declare const sql: { query(text: string): Promise<unknown[]> };   // your own SQL client

export async function GET(req: Request) {
  await auth.require(req);
  const orders = await sql.query("select * from orders limit 20");   // no owner scope
  return Response.json(orders);
}
```

Read through the scoped model instead, so the list only holds this user's orders:

```ts title="app/api/orders/route.ts (after)"
import { auth, models, s } from "mainsdk";

const { orders } = models({
  orders: s.object({ item: s.string(), total: s.int() }),
});

export async function GET(req: Request) {
  const session = await auth.require(req);
  const page = await orders.for(session.user).list({ limit: 20 });
  return Response.json(page);
}
```

A query through `models` without `.for(user)` does not compile, so look for other places that read orders through your own SQL client.

## 4. Look at the app's state (when the cause is a driver or a dependency)

```bash
npx mainsdk info
npx mainsdk status
```

`info` lists each interface with its driver, the network destinations and the health and resource settings. `status` shows dependencies, which are critical, and resource pressure. Tracing each provider call with `MAINSDK_DEBUG=driver` is planned.

## 5. Verify the fix

Repeat what the user did while following the error log:

```bash
npx mainsdk logs --level error --follow
```

Open the order list again: it shows only your own orders, and no new `MAIN-DATA-0007` line appears. Add a test so it stays fixed; see [Test your app](https://mainsdk.dev/docs/guides/testing).

## Send telemetry to your own OpenTelemetry backend

Planned: OTLP export of logs, traces and metrics to any endpoint, redacted in process, through a `telemetry` block in `main.yaml`. It is not built, and `main.yaml` has no `telemetry` key yet. Until then, give `configure()` a sink of your own: a `Sink` is a function `(kind, record) => void` that receives each redacted record.

## Debug production with MainSDK Cloud

> **Note:** Built in 0.6 and tested against the in-repo Cloud on an in-memory database. Sending to MainSDK Cloud needs `mainsdk login` and an explicit opt-in. Two parts are still the host's to wire: the GitHub app that opens issues and pull requests, and the screenshot upload through getqr.id. Both are ports with tested fakes.

Send logs to the Cloud with the sink from `mainsdk/feedback`. It sends nothing until you are logged in and have set `telemetry.to: [mainsdk]` or `MAINSDK_TELEMETRY=1`, and it replaces emails, bearer tokens and key formats with `[redacted]` before anything leaves the process.

```ts title="server/telemetry.ts"
import { cloudSink } from "mainsdk/feedback";

declare const session: { exists(): boolean };
declare const config: { telemetry: { to: string[] } };
declare const client: { emit: Parameters<typeof cloudSink>[0]["emit"] };

export const sink = cloudSink({
  loggedIn: () => session.exists(),
  to: () => config.telemetry.to,
  flag: () => process.env.MAINSDK_TELEMETRY,
  emit: (event) => client.emit(event),
});
```

| Task | Command | Plan |
| --- | --- | --- |
| Live tail from production, for one user, trace or level | `mainsdk logs --follow --env prod --user u_8f3` | Free; asks for a fresh passkey |
| Raise detail for one user, request or release, for up to 60 minutes; it switches itself off | `mainsdk debug start --user u_8f3 --for 30m` | Teams |
| End a debug session early | `mainsdk debug stop <session-id>` | Teams |
| Replay a failed request locally on fake drivers | `mainsdk replay 4bf92f3577b34da6a3ce929d0e0e4736` | Teams |
| Install the GitHub app for the project | `mainsdk triage connect github --installation <id>` | Free |
| Group errors, find the release and commit, open one GitHub issue per group | `mainsdk triage run` | Teams (hosted) |
| Open a draft fix pull request that waits for `check --json` to pass | `mainsdk triage fix <issue> --head <branch>` | Free |

A session names one user, one request or one release and lasts at most 60 minutes, so `--for 90m` runs for 60. The app reads the active sessions with its ingest key and keeps records below its log level only for the target, through the `raise` option of the log:

```ts title="server/log.ts"
import { Log } from "mainsdk";
import { raiseFor, type DebugSession } from "mainsdk/feedback";

declare const active: DebugSession[];   // the sessions your app read with its ingest key

export const log = new Log({ raise: raiseFor({ sessions: () => active, now: () => Date.now() }) });
```

Nothing is redeployed. A session ends itself on time, and its start and end are written to an audit record. Records stay redacted.

Replay needs a file, `mainsdk.replay.ts`, that exports a function for each action you want to replay. It runs the recorded input on the test app, where every interface is a fake, and any call that tries to reach the network is blocked and counted:

```ts title="mainsdk.replay.ts"
declare function refund(input: unknown, app: unknown): unknown;

export default {
  "orders.refund": (input: unknown, app: unknown) => refund(input, app),
};
```

```bash
mainsdk replay 4bf92f3577b34da6a3ce929d0e0e4736
```

```text
orders.refund: reproduced MAIN-DATA-0007; 0 outbound calls
```

On Free, `mainsdk debug start` and `mainsdk replay` get HTTP 402 from the Cloud with a link to Teams, and your app is unaffected. See [Collect customer reports](https://mainsdk.dev/docs/interfaces/feedback) for the report button that arrives with its trace.

> **Warning:** MainSDK never offers a remote shell or a debugger attached to production. Live tail, debug sessions and replay answer the same questions without opening a door into the running app. A test fails the build if any endpoint, command or driver executes code or attaches a debugger.

## See where telemetry goes

```bash
npx mainsdk telemetry status --json
```

It shows the destinations telemetry is sent to and whether MainSDK Cloud may receive it. Nothing is sent unless you declared it.

## Next

- [Error codes](https://mainsdk.dev/docs/reference/errors)
- [Test your app](https://mainsdk.dev/docs/guides/testing)
- [Free and Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud)
