---
title: "Logs, audit and telemetry"
description: "Structured logs with redaction built in, a tamper-evident audit log, and errors with a stable code and a fix. Export to OpenTelemetry is planned."
section: "interfaces"
status: "Building for 0.2 · ships as a stable API"
spec: "log-telemetry"
canonical: "https://mainsdk.dev/docs/interfaces/log"
updated: "2026-10-28"
---

# Logs, audit and telemetry

> Structured logs with redaction built in, a tamper-evident audit log, and errors with a stable code and a fix. Export to OpenTelemetry is planned.

Status: Building for 0.2 · ships as a stable API · Canonical: https://mainsdk.dev/docs/interfaces/log · Updated: 2026-10-28

`log` gives every app structured logs with redaction built in and a tamper-evident audit log. Errors thrown with `MainError` carry a stable code and a fix.

> **Note:** Logs, redaction, the audit chain, `MainError` and `mainsdk logs` are built and run from the repository (not published to npm). Planned, not built: OTLP export and the `telemetry` block of `main.yaml`, the `telemetry@1` contract (`MAIN-LOG-0005`), opt-in sending to MainSDK Cloud, a local event store written for you, and `mainsdk explain`.

## At a glance

- Status: Building for 0.2 · ships as a stable API
- Phase: 1 (Core, public beta)
- Milestone: 0.2 Core loop
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/log-telemetry.md
- Category: Observability and analytics

Calls:

- `log.info()`
- `log.error()`
- `log.audit()`
- `log.audit.verify()`
- `new MainError(code, { fix })`
- `problem(err)`
- `log.debug(msg, attrs?)`
- `log.warn(msg, attrs?)`
- `log.verifyAudit({ from? })`
- `verifyChain(records)`
- `currentTrace()`
- `newTraceId()`
- `withTrace(id, fn)`
- `fileSink(path)`
- `log.records`
- `log.auditRecords`
- `readAuditFile(path)`
- `instance(object: id)`
- `log.addRedactor(fn)`
- `log.addSecret(value)`
- `log.auditFor(tenant)`
- `log.reload(opts)`
- `log.holdsSecret(text)`
- `log.redactData(attrs)`
- `log.redactString(text)`
- `log.secretValues()`
- `log.errors.list(query?: { since?, release?, cursor?, limit? })`
- `log.errors.get(fingerprint)`
- `auditEvent(input)`
- `parseErrorReportConfig(logging)`
- `errorConfigIssues(logging, ctx)`
- `reportErrors(opts?: { endpoint?, sample? })`

In main.yaml:

```yaml
logging: { audit: true, redact: [personal, sensitive, child, phi] }
telemetry:
  to: [terminal, otel]
  otel: { endpoint: https://otel.pinecart.dev:4318, headers: { authorization: secret:OTEL_TOKEN } }
  sample: 0.2
logging:
  errors:
    driver: otlp            # otlp | sentry | local
    release: auto           # the build hash, else MAINSDK_RELEASE, else the package version
    process: true           # capture uncaught errors, flush, exit 1
    browser: false          # serve POST /_mainsdk/errors for reportErrors()
    ignore: [AbortError]
    alert: { new: warning, spike: { over: 50, per: 5m, severity: critical } }
```

Guarantees, whatever the driver:

- Personal data and secrets redacted in process before any write
- Append-only, hash-chained audit log in OCSF, never sampled
- Export never blocks a request
- Nothing sent to MainSDK Cloud without login and opt-in


## Drivers

Pick one in main.yaml with `telemetry.to`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Terminal | `terminal` | yes |  |
| Any OpenTelemetry backend | `otel` |  |  |
| MainSDK Cloud | `mainsdk` |  |  |
| Sentry (error events) | `sentry` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-REPORT-0005` | check | `logging.errors` is invalid: `sentry` with no `dsn`, a `dsn` host not in `network.allow`, a `sample` outside 0 to 1, or `alert.spike.over` under 1 |
| `MAIN-LOG-0020` | check | A password, key, token or authorization value is passed to a log call |
| `MAIN-LOG-0003` | runtime | `MAINSDK_DEBUG=driver` ignored in production |
| `MAIN-LOG-0005` | runtime | An attribute not in `telemetry@1` was dropped before export |
| `MAIN-LOG-0010` | runtime | Audit sink unavailable; buffering to local disk |
| `MAIN-REPORT-0001` | runtime |  |
| `MAIN-REPORT-0002` | runtime |  |
| `MAIN-REPORT-0003` | runtime |  |
| `MAIN-REPORT-0004` | runtime |  |
| `MAIN-REPORT-0006` | runtime |  |
| `MAIN-REPORT-0007` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Logs on the OpenTelemetry data model | 1 | 0.2 | `log-telemetry#log-api` |
| Local event store for 7 days | 1 | 0.2 | `log-telemetry#local-store` |
| In-process redaction | 1 | 0.2 | `log-telemetry#redactor` |
| Hash-chained audit log in OCSF | 1 | 0.2 | `log-telemetry#audit-log` |
| telemetry@1 field contract | 1 | 0.2 | `log-telemetry#telemetry-contract` |
| OTLP export | 1 | 0.2 | `log-telemetry#otlp-export` |
| Opt-in sending to MainSDK Cloud | 1 | 0.2 | `log-telemetry#opt-in` |
| MainError with problem details | 1 | 0.2 | `errors#main-error` |
| Unhandled errors and crashes captured with a stable fingerprint | 1 | 0.4 | `error-reporting#fingerprint` |
| Alerts on new and spiking error groups | 1 | 0.4 | `error-reporting#groups-alerts` |
| Sentry driver with no SDK | 1 | 0.4 | `error-reporting#sentry` |
| Opt-in browser error reports | 1 | 0.4 | `error-reporting#browser` |

## MainSDK Cloud

Everything on this page is free in your app.
Cloud adds:

| Plan | What it adds |
| --- | --- |
| teams | Pooled events with 30 days of history, a shared trace explorer, debug sessions and replay of failed requests |
| fleet | Observability across every app and OCSF streaming to the SIEM |


## Quick start

Logging works with no configuration: records print to the terminal, one line each.

TypeScript:

```ts
import { auth, log } from "mainsdk";

export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await auth.require(req);
  const { id } = await params;
  log.info("order shipped", { orderId: id, by: session.user });
  return new Response(null, { status: 204 });
}
```

Rust:

```rust
use mainsdk::prelude::*;
let app = App::test();
app.log().info("signed in").field("plan", "pro").await;
assert_eq!(app.log().records().len(), 1);
```


Each record has an id, an RFC 3339 UTC `time`, a `severity` (`DEBUG`, `INFO`, `WARN`, `ERROR`), a `body` and `attributes`. Inside a request that carries a trace, `attributes.trace_id` holds it, and in a tenant, `attributes["tenant.id"]` does.

## Configuration

```yaml title="main.yaml"
logging:
  audit: true                               # write the hash-chained audit log (default true)
  redact: [personal, sensitive, child, phi] # labels redacted before any write
```

| Key | Type | What it does |
| --- | --- | --- |
| `logging.audit` | boolean | Writes the append-only audit log; `false` turns it off |
| `logging.redact` | list of labels | Field labels redacted in process. Default `personal`, `sensitive`, `child`, `phi` |

A push to the [config store](https://mainsdk.dev/docs/interfaces/config) can only tighten these: add a label or turn audit on, never the reverse.

How long a person's log records are kept under data rights is set in [`privacy.retention.logs`](https://mainsdk.dev/docs/interfaces/privacy).

## What gets redacted

Before a record reaches any sink:

- Attributes labelled `personal` or another listed label (`email`, `phone` and `name` by default) are replaced with `[redacted]`.
- `authorization`, `cookie`, `set-cookie`, `password` and `pan` are always redacted.
- Anything resolved from a `secret:` reference, and known key formats such as `sk_live_…`, `AKIA…` and GitHub tokens, are masked in messages and attributes.
- An `ip` attribute is trimmed to its /24 (IPv4) or /48 (IPv6).

## What you get

Logs and the audit log are free and local from the first run. Redaction happens in process before any record is written. `problem()` turns a `MainError` into an RFC 9457 response. Reach for `log` everywhere you would use `console.log`, and wherever a decision must be provable later.

## Examples

### Throw an error with a fix

`MainError` takes a code, a message and options. `problem()` serialises it as `application/problem+json`; any other thrown value becomes a 500 that hides its message.

```ts title="app/api/orders/[id]/route.ts"
import { auth, MainError, models, problem, s } from "mainsdk";

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

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  try {
    const session = await auth.require(req);
    const { id } = await params;
    const order = await orders.for(session.user).get(id);
    if (!order) {
      throw new MainError("MAIN-DATA-0007", "This record belongs to another owner", {
        status: 403,
        fix: "Query with db.orders.for(user)",
      });
    }
    return Response.json(order);
  } catch (err) {
    return problem(err);
  }
}
```

```json title="403 response"
{
  "type": "https://mainsdk.dev/errors/MAIN-DATA-0007",
  "title": "This record belongs to another owner",
  "status": 403,
  "code": "MAIN-DATA-0007",
  "fix": "Query with db.orders.for(user)"
}
```

`retryable: true` appears when the error is retryable, and `retryAfter` sets a `Retry-After` header. `log.error(err)` writes the code and fix with the record.

### Write and verify an audit record

Policy decisions are audited for you. Write your own records for business events that must be provable. An event names the actor (`kind` is `user`, `agent`, `system` or `service`), the action, an optional resource string and a decision (`allow`, `deny`, `pending` or `approved`).

```ts title="app/api/admin/users/[id]/refund-limit/route.ts"
import { auth, log } from "mainsdk";

export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await auth.require(req, { role: "admin" });
  const { id } = await params;
  log.audit({
    actor: { kind: "user", id: session.user },
    action: "refund_limit.grant",
    resource: `user:${id}`,
    decision: "allow",
  });
  return new Response(null, { status: 204 });
}
```

```ts title="scripts/verify-audit.ts"
import { log } from "mainsdk";

const result = log.verifyAudit({ from: "2026-10-01T00:00:00Z" });
if (!result.ok) {
  console.error(`Audit chain broken at ${result.brokenAt}`);
  process.exit(1);
}
```

Each record carries the hash of the previous one, and each tenant has its own chain, so an edit, deletion or reorder makes `verifyAudit` return `ok: false` with the first broken record's id. To check records you read back from a file, use `verifyChain(records)`.

### Keep records in a file and read them with the CLI

Passing sinks replaces the terminal output. `fileSink` appends JSON lines, and `mainsdk logs` reads `.mainsdk/events/*.jsonl`.

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

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

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

Without `--json`, each line is the time, the severity and the message.

## Error reporting

Every unhandled error is captured with no wiring: a request whose handler throws or answers 5xx, a job that lands in the dead-letter queue, a workflow run that fails, `log.error(err)` with an `Error`, and (by default) an uncaught exception or unhandled rejection. A `MainError` with a status under 500 is the caller's error and is skipped. Each capture is redacted, given a fingerprint, counted into a group and queued for export, and none of it waits on the network.

The fingerprint is the first 16 hex characters of SHA-256 over the error's code or name and its first three frames in your code (path relative to the project, function name, no line number). It stays the same across restarts, machines and line moves. With no frame in your code it uses the message with digits, long hex and UUIDs replaced by `<n>`.

```yaml title="main.yaml"
logging:
  errors:
    driver: otlp          # otlp (through telemetry.otel) | sentry | local
    release: auto         # the build hash, else MAINSDK_RELEASE, else the package version
    process: true         # capture uncaught errors, flush for at most 2 s, exit 1
    browser: false        # serve POST /_mainsdk/errors for reportErrors()
    ignore: [AbortError]
    alert:
      new: warning        # a group new in this release; off to disable
      spike: { over: 50, per: 5m, severity: critical }
```

```ts title="app/errors.ts"
import { log } from "mainsdk";

const page = await log.errors.list({ since: "1h", limit: 20 }); // newest first, with a next cursor
const group = await log.errors.get(page.items[0]?.fingerprint ?? ""); // 404 when unknown
console.log(group.count, group.frame?.file, group.isNew);
```

```bash
npx mainsdk errors --since 1h --json   # the groups under mainsdk dev, newest first
```

A browser reporter is one opt-in call, with `logging.errors.browser: true` on the server:

```ts title="app/client.ts"
import { reportErrors } from "mainsdk/log/client";

const stop = reportErrors({ sample: 0.5 }); // listeners for error and unhandledrejection; stop() removes them
```

It sends `{ name, message, stack, path, release }` with the query and fragment removed, one report per distinct error per page load and at most 10 a minute. The server answers 404 when `browser` is off, 413 over 16 KB and 429 over 10 a minute per session or address (`MAIN-REPORT-0004`).

Newness is decided across instances through `kv` when the app declares a `kv` section; without it, each instance decides for itself. A new group logs `MAIN-REPORT-0001` and raises `error:<fingerprint>` once; a spike logs `MAIN-REPORT-0002` and raises `error-spike:<fingerprint>`, resolved when the window empties. The `sentry` driver writes Sentry's envelope itself, through the network allowlist, with no Sentry SDK; it is written from Sentry's documented shapes and is not yet verified against a live Sentry server.

## Reference

Calls beyond the examples above.

| Call | What it does |
| --- | --- |
| `log.debug(msg, attrs?)` | Writes a `DEBUG` record. It is dropped unless the level is `DEBUG`. |
| `log.warn(msg, attrs?)` | Writes a `WARN` record. |
| `log.records` | The records this process kept in memory, oldest first. Tests read it to assert on what the app logged. |
| `log.auditRecords` | Every audit record this process kept, across tenants, in the order written. `log.auditFor(tenant)` narrows it to one chain. |
| `log.audit.verify()` | The specified name for chain verification. In 0.x the working call is `log.verifyAudit({ from? })`, which returns `{ ok, brokenAt? }` and checks each tenant's chain. |
| `log.auditFor(tenant)` | Returns one tenant's audit records in order, or the `system` chain. They verify on their own with `verifyChain`. |
| `log.reload(opts)` | Changes `audit` and the redacted labels while the app runs, with `{ audit, redact? }`. |
| `log.redactData(attrs)` | Redacts attributes the way a record would be, for data that leaves in another envelope. |
| `log.redactString(text)` | Redacts a string: secret values, known key formats and registered redactors. |
| `log.holdsSecret(text)` | Returns `true` when the text holds a registered secret value or a known key format. |
| `log.addSecret(value)` | Registers a resolved secret value so it never appears in any record. |
| `log.secretValues()` | Returns a copy of the registered secret values for scans that must refuse them. Never log it. |
| `log.addRedactor(fn)` | Registers a function that rewrites every string in every record, such as one that masks an API key format. |
| `readAuditFile(path)` | Reads audit records back from a JSON-lines file written by `fileSink`; a missing file gives an empty list. |
| `newTraceId()` | Returns a new trace id: 32 lowercase hex characters. |
| `currentTrace()` | Returns the trace id of the work running now, or `undefined` outside any. |
| `withTrace(id, fn)` | Runs `fn` under a trace id, so the jobs and records it makes carry the same `trace_id`. |
| `instance(object: id)` | `instance.id` identifies this running copy of the app. It is random on each start. |

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-LOG-0003` | `MAINSDK_DEBUG=driver` set in `prod` (planned) | Unset it, or start a debug session |
| `MAIN-LOG-0005` | The exporter tried to send an attribute not listed in `telemetry@1` (planned) | Remove the attribute, or keep it in local logs only |
| `MAIN-LOG-0010` | An audit sink threw; the record stays in memory and an `ERROR` record carries this code | Restore the sink; the audit chain is intact |
| `MAIN-REPORT-0001` | An error group is new in this release | Read the group with `log.errors.get`, then fix it or `ignore` it |
| `MAIN-REPORT-0002` | An error group passed `alert.spike` | Find the deploy or the dependency behind it |
| `MAIN-REPORT-0003` | The error driver could not deliver; events are dropped (logged once per outage) | Check the endpoint or DSN and `network.allow` |
| `MAIN-REPORT-0004` | A browser report was refused: off (404), over 16 KB (413) or over 10 a minute (429) | Set `logging.errors.browser: true`, or send fewer and smaller reports |
| `MAIN-REPORT-0005` | `logging.errors` is invalid (check) | Give `sentry` a `dsn` whose host is in `network.allow`, a `sample` from 0 to 1 and `alert.spike.over` of 1 or more |
| `MAIN-REPORT-0006` | The process hit an uncaught error; it was captured as `fatal` and exits with code 1 | Read the fatal group and fix the cause |
| `MAIN-REPORT-0007` | `log.errors.get` found no group with that fingerprint (404) | List the groups first |

Every code is listed in [error codes](https://mainsdk.dev/docs/reference/errors) and [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

The `terminal` sink is the only built-in destination, plus `fileSink` and any function of your own with the `Sink` shape. A planned `otel` driver will export to Datadog, Grafana, Sentry or any OpenTelemetry backend, and a planned `mainsdk` driver to MainSDK Cloud; your `log.*` calls and audit records stay the same.

## Testing

The test app keeps every record in memory, so assert on `app.log.records` and `app.log.auditRecords`.

```ts title="app/log.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { MainError } from "mainsdk";

describe("logging", () => {
  it("redacts personal fields and chains audit records", () => {
    const app = createTestApp({ main: "./main.yaml" });
    app.log.info("signup", { email: "a@b.co" });
    assert.equal(app.log.records.at(-1)?.attributes.email, "[redacted]");

    app.log.audit({ actor: { kind: "user", id: "u1" }, action: "plan.change", decision: "allow" });
    assert.equal(app.log.verifyAudit().ok, true);
  });

  it("turns a MainError into problem details", () => {
    const body = new MainError("MAIN-DATA-0007", "Not yours", { status: 403, fix: "Read through db.orders.for(user)" }).toProblem();
    assert.equal(body.code, "MAIN-DATA-0007");
  });
});
```

See the [Testing kit](https://mainsdk.dev/docs/interfaces/testing).

## Security and privacy

- Redaction runs in process before any write: labelled fields, secrets and known key formats.
- IP addresses are trimmed; `authorization`, `cookie` and `set-cookie` values never appear.
- Nothing leaves your app: no destination is built that sends logs elsewhere. When export arrives it will be opt-in, with a field contract that drops anything not listed.

## FAQ

**Is the audit log ever sampled?** No. Every audit event is chained and kept.

**Where does the audit log live in production?** In memory, plus any sink you pass, such as `fileSink`. A database-backed or OCSF-stream sink is not decided yet.

**What if an audit sink fails?** The record stays in memory, an `ERROR` record with `MAIN-LOG-0010` is written, and the call does not throw.

## Next

- [Error codes](https://mainsdk.dev/docs/reference/errors): every `MAIN-` code with its cause and fix.
- [Policy decisions](https://mainsdk.dev/docs/interfaces/policy): decisions written to the audit log.
- [Debug your app](https://mainsdk.dev/docs/guides/debugging): from an error to a fix.
- [Fail static](https://mainsdk.dev/docs/concepts/fail-static): why logging never blocks a request.

## Helpers for wiring and tests

| Call | What it does |
| --- | --- |
| `auditEvent(input)` | Build one audit event from an input, as `log.audit` does before it writes. |
| `parseErrorReportConfig(logging)` | Read the error reporting settings under `logging` in `main.yaml`. |
| `errorConfigIssues(logging, ctx)` | List what is wrong in the error reporting settings, without throwing. |
