Devtools

@beignet/devtools gives local apps a live timeline for Beignet activity: requests, errors, use cases, domain events, jobs, outbox delivery, schedules, payments, feature flags, entitlements, policies, and provider activity.

bun add @beignet/devtools

Setup

1. Register the provider

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

export const server = await createNextServer({
  ports,
  providers: [createDevtoolsProvider(), ...otherProviders],
  context: async ({ ports, requestId, trace }) => ({
    requestId,
    ...trace,
    ports,
  }),
});

The provider is the only wiring devtools needs. The server itself owns request instrumentation: createServer(...) resolves a request ID and W3C trace context for every request, writes the x-request-id and traceparent response headers, and records request and error events into the resolved provider instrumentation port. Requests under /api/devtools are ignored by default so dashboard traffic does not pollute the timeline. See request lifecycle for the instrumentation option that configures headers, ignored paths, redaction, and capture decisions.

Devtools does not require the OpenTelemetry SDK, but events are shaped for OTel-compatible correlation with traceId, spanId, parentSpanId, and traceparent from @beignet/core/tracing. Spread the trace context argument into your app context so deeper instrumentation stays 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 devtools dashboard showing an expanded request with overview facts, a lifecycle waterfall of correlated spans, and correlated activity grouped by category

The dashboard streams live events over Server-Sent Events, groups views by section in the sidebar, and supports search (/), method/status/watcher filters, Pause/Resume, and Clear. Request rows expand into an end-to-end lifecycle view for events sharing the same traceId or requestId: a waterfall of correlated spans, overview facts, correlated activity grouped by category, and the raw JSON. Start there when debugging a route. The errors view groups failures by owner — route, framework, provider, job, schedule, outbox, client-side, devtools, or unknown — and each subsystem view shows domain-specific metrics such as cache hits/misses, outbox attempts, or rate limit decisions. The payments view focuses on checkout sessions, portal sessions, refunds, verified webhooks, provider IDs, and failures. The entitlements view focuses on paid product access decisions, subjects, denial reasons, and check sources. The policies view focuses on observed authorization decisions, abilities, denial reasons, and batch sources.

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,
    outbox: true,
    schedules: true,
    providers: true,
    db: true,
    cache: true,
    payments: true,
    entitlements: true,
    custom: true,
  },
});

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

Custom integrations can register watcher metadata too. Custom watcher views appear in the dashboard sidebar 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. Use cases are instrumented automatically

createUseCase(...) instruments every run by default. No devtools-specific wiring is needed:

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

export const useCase = createUseCase<AppContext>();

Each run resolves the instrumentation port from ctx.ports, reads ctx.requestId and trace context fields, and records usecase events that share one nested span across start, end, and error phases. Failed runs also record correlated error events. Without an installed sink, runs stay silent. Pass instrumentation: false to opt out.

Provider instrumentation

Providers record external work through createProviderInstrumentation() from @beignet/core/providers instead of depending on devtools directly; @beignet/devtools implements the instrumentation port that helper resolves. When provider instrumentation records an event during an active request, devtools fills in the active requestId, traceId, and traceparent so provider work stays correlated with the route, hook, and use-case timeline. See Writing a provider for the instrumentation conventions, resolution order, and watcher guidance.

Audit activity

Durable audit logs should still be written through your app's AuditLogPort. Use createInstrumentedAuditLog() from @beignet/core/ports when local debugging should also show sanitized audit activity:

import { createInstrumentedAuditLog } from "@beignet/core/ports";

const audit = createInstrumentedAuditLog({
  audit: durableAudit,
  instrumentation: ports,
});

The wrapper records the durable audit entry first, then emits a custom event owned by the audit watcher into the resolved instrumentation port. 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].

Server request instrumentation records request headers for debugging, but it does not record request or response bodies by default. The request lifecycle view marks stored events when sensitive fields were redacted and warns if secret-shaped metadata keys remain visible.

Add an app-owned redactor through the server instrumentation option:

const server = await createNextServer({
  // ...
  instrumentation: {
    redact: (event) => ({
      ...event,
      details: scrub(event.details),
    }),
  },
});

Event types

TypeDescriptionKey fields
requestHTTP request handlingmethod, path, status, durationMs, responseOwner
errorErrorsmessage, stack, contractName, useCaseName, owner
usecaseUse case executionname, kind, phase, durationMs
eventBusDomain event publishingeventName
jobBackground job lifecyclejobName, status
scheduleSchedule 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. Generated apps wire DEVTOOLS_ENABLED through lib/env.ts: the devtools route is enabled when DEVTOOLS_ENABLED=true, disabled when DEVTOOLS_ENABLED=false, and development-only by default. The starter's sidebar reads the same value, so the Devtools link appears exactly when the route is available.

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.