---
title: "Observe the SDK"
description: "Subscribe to every decision and side effect the SDK makes, on node:diagnostics_channel, with redacted frozen payloads, a bounded queue per observer and no cost when nobody listens."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "observe"
canonical: "https://mainsdk.dev/docs/interfaces/observe"
updated: "2026-10-28"
---

# Observe the SDK

> Subscribe to every decision and side effect the SDK makes, on node:diagnostics_channel, with redacted frozen payloads, a bounded queue per observer and no cost when nobody listens.

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/observe · Updated: 2026-10-28

`observe` lets your code, a test or a tracing library watch what the SDK decides and does. It is read-only: an observer can't veto or change a call.

It covers each network, permission and access decision, each AI, MCP, files, pay and share call, and spans for fetches, driver calls and actions. One that throws or stalls never changes a request. Reach for it to feed a metrics tool, to assert in tests, or to debug a refused call.

## At a glance

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

Calls:

- `observe(pattern, fn, opts?)`
- `observe.channels()`
- `observe.schema(name)`
- `observe.flush()`
- `observe.stats()`
- `observe.otel(api?)`
- `registerChannel(def)`
- `removeChannel(name, version)`
- `detailScope(opts, fn)`
- `tracedDriver(driver, { publisher, interface, ops })`
- `app.observe(pattern, fn, opts?)`

In main.yaml:

```yaml
# Observers are code, not configuration. Debug output is an environment variable:
#   MAINSDK_DEBUG=observe                         print every event, with detail fields (dev only)
#   MAINSDK_DEBUG=mainsdk:access.*,mainsdk:network.*   only matching channels
schema: 1
```

Guarantees, whatever the driver:

- An observer that throws or stalls never changes a request
- Payloads are redacted and frozen before any observer sees them
- A channel with no subscriber costs one property read


## Drivers

Pick one in main.yaml with `observe.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in (core | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-OBS-0001` | runtime | An observer threw or rejected; caught, the request is untouched, and the observer keeps receiving |
| `MAIN-OBS-0002` | runtime | An observer's queue was full and its oldest events were dropped |
| `MAIN-OBS-0003` | runtime | An observer's synchronous work took over 10 ms and blocked the event loop |
| `MAIN-OBS-0004` | runtime | A published payload breaks its channel schema |
| `MAIN-OBS-0005` | runtime | `observe()` named a channel that isn't in the registry, or a pattern that matches none |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Diagnostics channels with a versioned registry | 1 | 0.3 | `observe#registry` |
| Bounded observer queues with drop reports | 1 | 0.3 | `observe#core` |
| Spans for fetch | 1 | 0.3 | `observe#tracing` |
| Test kit app.events | 1 | 0.3 | `observe#testing-events` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```ts title="lib/watch.ts"
import { observe } from "mainsdk";

const stop = observe("mainsdk:network.*", (e) => {
  console.log(e.channel, e.data.host, e.data.decision);
});
// later: stop()
```

The pattern is an exact channel name or one ending in `*`. A name that matches no channel throws `MAIN-OBS-0005` and names the closest one. The full list is on the [channels reference](https://mainsdk.dev/docs/reference/channels).

## Configuration

There is no `main.yaml` section: observers are code, registered at start. These calls and settings shape them.

- `observe(pattern, fn, opts?)` subscribes and returns `stop`. `opts.max` bounds the queue (default 1,000).
- `app.observe(pattern, fn, opts?)` is the same for one app.
- `observe.channels()` lists every channel with its version, kind, publisher and JSON Schema.
- `observe.schema(name)` returns the JSON Schema 2020-12 of one payload.
- `observe.flush()` waits until every queue is drained. `observe.stats()` reports delivered and dropped counts.
- `observe.otel(api?)` bridges spans to an OpenTelemetry API you pass in, using only the allowed attributes.
- `registerChannel(def)` and `removeChannel(name, version)` add or remove a channel entry. A changed shape ships as `@2` beside `@1`.
- `detailScope(opts, fn)` turns debug detail on for `fn` for up to 60 minutes. In development `MAINSDK_DEBUG=observe`, or a list of channel patterns, prints matching events with their detail fields. In production `MAINSDK_DEBUG` is ignored and `MAIN-LOG-0003` is logged once.
- `tracedDriver(driver, { publisher, interface, ops })` wraps a driver's async operations in `driver.call` spans.

## What you get

Every event has the same envelope: `channel`, `schema`, `kind`, `phase`, `id`, `time`, `app`, `tenant`, `user`, `traceId`, `spanId`, `parentSpanId`, `caller`, `data` and `detail`. The payload is redacted with the log rules, URL query values are removed, and the object is frozen. An observer does not receive events its own calls caused.

Observers run off the request path. When a queue is full the oldest event is dropped and `MAIN-OBS-0002` is logged at most once a minute. A throw is caught and logged as `MAIN-OBS-0001`. Synchronous work over 10 ms is reported as `MAIN-OBS-0003`.

## Errors and checks

The `MAIN-OBS-0001` to `0005` codes above are runtime codes: a throwing observer, a full queue, slow synchronous work, a payload that breaks its schema and an unknown channel. See [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

`observe` has no drivers. It is part of the core, and the same calls work whatever drivers the app uses. `tracedDriver` wraps a driver's calls in `driver.call` spans, and `observe.otel()` bridges spans to OpenTelemetry.

## Testing

`createTestApp()` gives `app.events(pattern?)`, which returns the recorded events at once, and `app.events.clear()`. A payload with a field its schema doesn't list throws `MAIN-OBS-0004`.

## In development

With no setup, events from the dev server are written to `.mainsdk/events/channels.jsonl`, and `mainsdk logs --channel "mainsdk:network.*"` reads them.

## Next

- [Channels reference](https://mainsdk.dev/docs/reference/channels)
- [Logging](https://mainsdk.dev/docs/interfaces/log)
- [Test your app](https://mainsdk.dev/docs/guides/testing)
- [Watch what your app decides](https://mainsdk.dev/docs/guides/watch-what-your-app-decides)
