Devtools

@beignet/devtools gives local apps a live timeline for Beignet activity: requests, errors, use cases, domain events, jobs, schedules, and provider activity.

bun add @beignet/devtools

Setup

1. Register the provider and hook

import {
  createDevtoolsHooks,
  createDevtoolsProvider,
} from "@beignet/devtools";
import { createNextServer } from "@beignet/next";

export const server = await createNextServer({
  ports,
  providers: [createDevtoolsProvider(), ...otherProviders],
  hooks: [createDevtoolsHooks()],
  createContext: async ({ req, ports }) => ({
    requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
    ports,
  }),
});

createDevtoolsHooks() records HTTP request timing and errors. It skips /api/devtools by default so dashboard traffic does not pollute the timeline. When a request does not already have an ID, the hook generates one and exposes it with the x-request-id response header.

The hook also reads and writes the W3C traceparent header. Devtools does not require the OpenTelemetry SDK, but events are shaped for OTel-compatible correlation with traceId, spanId, parentSpanId, and traceparent. For object contexts, the hook adds those fields before the handler runs so deeper instrumentation can stay on the same trace.

2. Add the dashboard route

// app/api/devtools/[[...path]]/route.ts
import { createDevtoolsRoute } from "@beignet/devtools";
import { server } from "@/server";

export const { GET, POST } = createDevtoolsRoute(server.ports.devtools, {
  basePath: "/api/devtools",
});

Visit /api/devtools in development.

The dashboard uses Server-Sent Events for live updates and falls back to polling when needed. It includes tabs for the full timeline, requests, use cases, errors, domain events, jobs, schedules, providers, provider-owned events such as database/cache/storage/core/mail/auth/audit/rate limit activity, and custom events. Request rows expand to show events with the same traceId or requestId.

Use the toolbar to search across event summaries, paths, messages, names, watchers, IDs, and details. Method, status, and watcher filters help narrow noisy timelines.

Provider-owned tabs render focused panels with subsystem-specific metrics and rows. For example, database shows query/provider context, cache shows hits and failures, auth shows authenticated versus guest activity, audit shows durable activity records, and rate limits show allowed versus blocked checks.

3. Optional local persistence

The default buffer is in memory. Enable local persistence when you want the timeline to survive dev server restarts:

import {
  createDevtoolsProvider,
  createFileDevtoolsStore,
} from "@beignet/devtools";

createDevtoolsProvider({
  store: createFileDevtoolsStore({
    filePath: ".beignet/devtools/core/events.jsonl",
  }),
});

You can also enable the built-in file store with environment variables:

DEVTOOLS_PERSIST=true
DEVTOOLS_PERSIST_PATH=.beignet/devtools/core/events.jsonl

The file store writes JSONL and compacts to the most recent configured events. POST /api/devtools/clear clears the in-memory buffer and the configured store.

4. Configure watchers

Watchers own capture for each subsystem. Configure them through createDevtoolsProvider(...):

createDevtoolsProvider({
  watchers: {
    requests: true,
    errors: true,
    useCases: true,
    eventBus: false,
    jobs: false,
    schedules: true,
    providers: true,
    db: true,
    cache: true,
    custom: true,
  },
});

Disabled watchers do not store matching events. The built-in watchers are requests, errors, useCases, eventBus, jobs, schedules, providers, db, cache, storage, mail, auth, audit, rateLimit, and custom.

Custom integrations can register watcher metadata too. Custom watcher tabs appear in the dashboard when they own custom events:

createDevtoolsProvider({
  watchers: {
    search: {
      label: "Search",
      description: "Search query and indexing diagnostics.",
      eventTypes: ["custom"],
    },
  },
});

Then record events with watcher: "search" so that custom watcher controls whether they are stored.

5. Instrument use cases

Use a shared application builder:

import { createUseCase } from "@beignet/core/application";
import { createDevtoolsUseCaseObserver } from "@beignet/devtools";

export const useCase = createUseCase<AppContext>({
  onRun: createDevtoolsUseCaseObserver<AppContext>(),
});

The observer reads ctx.ports.devtools, ctx.requestId, and trace context fields, so use case events are correlated with the request that triggered them and share one nested span across start, end, and error phases.

Provider instrumentation

Providers should use createProviderInstrumentation() from @beignet/core/providers instead of depending on devtools directly. The helper accepts either a ports object or an instrumentation port, records through record(), and adds provider metadata to custom events. @beignet/devtools implements that instrumentation port when createDevtoolsProvider() is registered.

import {
  createProvider,
  createProviderInstrumentation,
} from "@beignet/core/providers";

export const searchProvider = createProvider({
  name: "search",
  setup({ ports }) {
    const instrumentation = createProviderInstrumentation(ports, {
      providerName: "search",
      watcher: "custom",
    });

    return {
      ports: {
        search: {
          async query(text: string) {
            const results = await runSearch(text);

            instrumentation.custom({
              name: "search.query",
              label: "Search query",
              summary: `${results.length} results`,
              details: { resultCount: results.length },
            });

            return results;
          },
        },
      },
    };
  },
});

Use db, cache, storage, mail, auth, audit, rateLimit, or schedules when the provider belongs to one of those categories. Use custom or a custom watcher name for application-specific integrations.

First-party providers use these watcher tabs where possible:

WatcherProviders
dbDrizzle/Turso provider and app-level database adapters
cacheRedis provider
storageLocal and S3 storage providers
mailResend, SMTP, and memory mailers
authBetter Auth provider
auditApp-owned audit log adapters mirrored through devtools
rateLimitUpstash rate-limit provider
jobsInngest jobs
schedulesScheduled task runners and provider adapters
eventBusIn-memory event bus

Audit activity

Durable audit logs should still be written through your app's AuditLogPort. Use createDevtoolsAuditLog() when local debugging should also show sanitized audit activity:

import { createDevtoolsAuditLog } from "@beignet/devtools";

const audit = createDevtoolsAuditLog({
  audit: durableAudit,
  devtools: ports.devtools,
});

The wrapper records the durable audit entry first, then emits a custom devtools event owned by the audit watcher. Devtools remains a local diagnostic view; it is not the durable audit store.

When an audit port is transaction-scoped, emit the devtools mirror only after the transaction commits. Keeping the transaction-scoped audit port durable-only is preferable to showing a local audit event for work that later rolls back.

Manual events

Use record() when application code wants to add a custom event. It fills id and timestamp for you.

ctx.ports.devtools.record({
  type: "custom",
  watcher: "search",
  name: "search.query",
  label: "Search query",
  summary: "24 results in 18ms",
  details: {
    query,
    resultCount: 24,
    durationMs: 18,
  },
});

Use log() only when you already have a complete DevtoolsEvent.

Redaction

Devtools uses the shared redaction helpers from @beignet/core/ports before events are stored. Sensitive keys such as authorization, cookie, set-cookie, x-api-key, token, password, secret, and credentials are replaced with [redacted].

Request hooks record request headers for debugging, but they do not record request or response bodies by default.

createDevtoolsHooks({
  redact: (event) => ({
    ...event,
    details: scrub(event.details),
  }),
});

Event types

TypeDescriptionKey fields
requestHTTP request handlingmethod, path, status, durationMs
errorErrorsmessage, stack, contractName, useCaseName
usecaseUse case executionname, kind, phase, durationMs
eventBusDomain event publishingeventName
jobBackground job lifecyclejobName, status
scheduleScheduled task executionscheduleName, status, cron, timezone
providerProvider lifecycleproviderName, action
customApp-specific diagnosticsname, label, summary, details

All events share id, timestamp, an optional requestId, optional traceId, optional spanId, optional parentSpanId, optional traceparent, and an optional watcher for custom watcher ownership.

Endpoints

EndpointDescription
GET /api/devtoolsDashboard UI
GET /api/devtools/core/eventsJSON event list
GET /api/devtools/streamServer-Sent Events stream
POST /api/devtools/clearClear the in-memory buffer and configured store

The events endpoint accepts type, requestId, traceId, and limit query parameters.

Configuration

The provider controls whether events are recorded. The HTTP route controls whether those events are exposed. Both default to development-only behavior.

DEVTOOLS_ENABLED=true
DEVTOOLS_ENABLED=false
DEVTOOLS_MAX_EVENTS=1000
DEVTOOLS_PERSIST=true
DEVTOOLS_PERSIST_PATH=.beignet/devtools/core/events.jsonl

The default in-memory buffer keeps the latest 500 events. The events endpoint returns the latest 200 unless a limit query parameter is provided. Persistence is opt-in and uses .beignet/devtools/core/events.jsonl by default when enabled without a custom path.

Route handlers return 404 when NODE_ENV === "production" unless explicitly enabled. For staging or internal production diagnostics, add application-owned authorization:

export const { GET, POST } = createDevtoolsRoute(server.ports.devtools, {
  basePath: "/api/devtools",
  enabled: process.env.DEVTOOLS_ENABLED === "true",
  authorize: (req: Request) =>
    req.headers.get("x-devtools-token") === process.env.DEVTOOLS_TOKEN,
});

If authorize returns false, devtools responds with 404. If it returns a Response, that response is used.