Error reporting and alerting

Beignet separates three observability concerns:

Use ErrorReporterPort when application code needs to report an unexpected failure without importing a vendor SDK.

Capture errors

Declare errorReporter as an app port and report unexpected failures from use cases, jobs, schedules, outbox drains, and tasks:

await ctx.ports.errorReporter.captureException(error, {
  level: "error",
  requestId: ctx.requestId,
  traceId: ctx.traceId,
  tags: {
    feature: "billing",
  },
  contexts: {
    tenant: { id: ctx.tenant.id },
  },
});

Capture messages for important non-exception signals:

await ctx.ports.errorReporter.captureMessage("Payment provider degraded", {
  level: "warning",
  tags: { provider: "stripe" },
});

Expected business failures such as validation failures, not-found results, denied policies, and known catalog errors usually belong in logs or audit records, not high-priority exception alerts.

Setup with Sentry

Install the Sentry provider:

bun add @beignet/provider-error-reporting-sentry @sentry/node

Register it in server/providers.ts. Provider-contributed ports replace earlier bound ports during startup, so a generated app can keep its local createNoopErrorReporter() fallback and let this provider replace it when Sentry is installed.

import { createSentryErrorReportingProvider } from "@beignet/provider-error-reporting-sentry";

export const providers = [
  createSentryErrorReportingProvider({
    dsn: process.env.SENTRY_DSN,
    init: {
      environment: process.env.NODE_ENV,
    },
  }),
];

The provider contributes ctx.ports.errorReporter and ctx.ports.sentry as an escape hatch for advanced Sentry operations. With no dsn or SENTRY_DSN, the provider still contributes the Beignet port but does not initialize Sentry.

When the app registers a separate OpenTelemetry SDK, configure init: { skipOpenTelemetrySetup: true } so Sentry remains the error reporter without installing a second tracing pipeline. See OpenTelemetry for the combined provider order.

HTTP request errors

Install createErrorReportingHooks(...) in server/index.ts to capture unexpected route, hook, response-validation, and server failures. The hook observes caught errors without changing response mapping. Use mapUnhandledError only to decide the response body.

import {
  createErrorReportingHooks,
  createIdempotencyHooks,
} from "@beignet/core/server";
import { createNextServer, createNextServerLoader } from "@beignet/next";
import type { AppContext } from "@/app-context";
import { initialPorts } from "@/infra/port-wiring";
import { appContext } from "./context";
import { routes } from "./routes";

export const getServer = createNextServerLoader(async () => {
  const { providers } = await import("./providers");

  return createNextServer({
    ports: initialPorts,
    providers,
    hooks: [
      createErrorReportingHooks<AppContext>(),
      createIdempotencyHooks<AppContext>(),
    ],
    context: appContext,
    routes,
    mapUnhandledError: ({ err, ctx }) => {
      ctx?.ports.logger.error("Unhandled API error", {
        error: err,
        requestId: ctx?.requestId,
      });

      return {
        status: 500,
        body: {
          code: "INTERNAL_SERVER_ERROR",
          message: "Internal server error",
          requestId: ctx?.requestId,
        },
      };
    },
  });
});

By default the hook captures unexpected failures and skips expected application catalog errors, auth denials, tenant requirements, idempotency conflicts, entitlement denials, and request validation failures. Pass shouldReport or reportOptions when an app needs different alerting rules or extra tags. Reporting preparation, capture, and failure observation are independently bounded to one second so a reporting backend cannot hold an HTTP response open. Set timeoutMs to tune the bound. Use timeoutMs: false only when intentionally allowing reporting to block request completion. Callback, resolver, reporter, and timeout failures are isolated and may be observed through onReporterError.

Runtime ownership

Report a logical failure once at the boundary that knows it is terminal:

BoundaryReporting ownerDefault behavior
HTTPcreateErrorReportingHooks(...)Reports unexpected caught failures; skips catalog, auth, policy, tenancy, idempotency, entitlement, and validation outcomes.
BullMQ jobscreateBullMQJobWorker(...)Reports terminal, invalid, unknown-job, and worker infrastructure failures. Retry attempts remain instrumentation.
Inngest jobscreateInngestJobFunction(...)Reports through native onFailure after Inngest exhausts retries.
Next schedule routescreateScheduleRoute(...)Reports a failed invocation that the route converts to a 500 response.
Next/CLI outbox drainsDrain adapterReports dead letters, settlement failures, and drain infrastructure failures; scheduled retries are not incidents.
CLI tasks and schedulesCLI runnerReports terminal failures and performs a bounded flush before context shutdown.
Registered listenersApp-owned registration providerThe canonical generated provider reports from registerListeners(...).onError.
Direct core primitive callsCalling appUse tryReportException(...) at the outer boundary that decides the failure is terminal.

Do not also report inside a handler when its worker, route, or runner owns the terminal boundary; doing both creates duplicate incidents. Prefer durable failure state for work that must be retried or reconciled. Error reporting tells an operator something went wrong; it does not make the work durable.

What context to send

Attach stable identifiers, not raw payloads:

FieldUse
requestIdCorrelate logs, devtools, traces, and support tickets
traceIdConnect nested use case, provider, outbox, and job events
actorIdIdentify the user, service, or anonymous actor
tenantIdScope the failure to a tenant or workspace
contractNameIdentify the HTTP boundary
useCaseNameIdentify the application workflow
jobNameIdentify background work
outboxMessageIdReconcile durable delivery failures
resourceType and resourceIdFind the affected record

Do not send request bodies, raw provider responses, access tokens, cookies, passwords, PHI, payment details, private messages, or full authorization headers unless your reporting vendor and retention policy are approved for that data.

Beignet applies default sensitive-key redaction to structured Sentry metadata, but it cannot safely rewrite exception messages or infer domain-sensitive fields. AppError.details is not automatically redacted and is never copied by the default HTTP hook; treat manual capture of an AppError as an explicit data export decision.

Use Privacy lifecycle to define which fields may leave app-owned storage and which fields must only appear as stable identifiers.

Testing

createTestPorts(...) includes an in-memory errorReporter port by default. Use it directly when testing failure paths:

const { ports, errorReporter } = createTestPorts<AppPorts>();

await useCase.run({ ctx: { ports }, input });

expect(errorReporter.reports).toEqual([
  expect.objectContaining({
    type: "exception",
  }),
]);

You can also import the memory reporter directly:

import { createMemoryErrorReporter } from "@beignet/core/error-reporting";

const errorReporter = createMemoryErrorReporter();

Devtools versus production reporting

Devtools are local diagnostics. When devtools are installed before an error reporting provider, captured exceptions and messages appear under the Errors view with request and trace correlation.

Use devtools locally, structured logs in production, and a production error reporter for exceptions that operators need to triage.

Alerting

Do not alert on every captured exception. Alert on symptoms that require human action:

Start with slow, high-signal alerts and add more only when they lead to useful operator action. Every alert should have an owner, a severity, a runbook link, and enough context to find the affected tenant or resource.