Logging

Logging belongs at the application and infrastructure boundary. Use structured logs for request flow, use case milestones, provider diagnostics, and failures that need production visibility.

Beignet keeps logging behind a port so use cases can emit useful context without depending on a specific logger.

Use Audit and activity logging for durable business activity records that need actor, tenant, request, and resource history. Use LoggerPort for diagnostic runtime logs.

Use Error reporting and alerting when exceptions need to be sent to an external system or turned into operator alerts.

Use Privacy lifecycle to decide retention, redaction, and what should never leave app-owned storage.

Setup

The Quickstart app already installs Pino and registers createPinoLoggerProvider() in server/providers.ts. Configure that existing entry; keep the other providers, hooks, and server context in place.

For example, change the Pino entry to emit JSON logs with a service name:

--- a/server/providers.ts
+++ b/server/providers.ts
@@ -20 +20 @@
-	createPinoLoggerProvider(),
+	createPinoLoggerProvider({ level: "info", format: "json", service: "todos" }),

LOG_LEVEL=debug enables more diagnostics when no level is specified in code; the explicit level above takes precedence.

For a manually assembled app, install @beignet/provider-logger-pino and pino, then register its factory in your existing provider list. See Providers for port wiring.

createPinoLoggerProvider(options) configures level, format, service, and timestamp in code; options override env-derived values.

The provider reads LOG_LEVEL, LOG_FORMAT, LOG_SERVICE, and LOG_TIMESTAMP from environment variables. Use LOG_FORMAT=json in production. Use LOG_FORMAT=pretty locally when pino-pretty is installed. The provider flushes its Pino logger when server.stop() runs so buffered destinations can drain before shutdown. When Beignet creates a worker-backed pretty transport, it closes that transport during the same lifecycle step. Structured metadata and child bindings pass through Beignet's recursive credential redaction before Pino writes them. This applies to the lifecycle provider and the direct adapter.

When the app owns Pino transports or destinations, use the direct adapter:

// infra/logging.ts (excerpt)
import { createPinoLogger } from "@beignet/provider-logger-pino";

const logger = createPinoLogger({ logger: appPinoLogger });

This returns LoggerPort without creating or closing the Pino instance.

Port shape

LoggerPort is exported by @beignet/core/ports:

export interface LoggerPort {
  trace(message: string, meta?: Record<string, unknown>): void;
  debug(message: string, meta?: Record<string, unknown>): void;
  info(message: string, meta?: Record<string, unknown>): void;
  warn(message: string, meta?: Record<string, unknown>): void;
  error(message: string, meta?: Record<string, unknown>): void;
  fatal(message: string, meta?: Record<string, unknown>): void;
  child(bindings: Record<string, unknown>): LoggerPort;
}

Log an operation

Add a diagnostic after the authoritative write succeeds. Update createTodoUseCase in features/todos/use-cases.ts:

--- a/features/todos/use-cases.ts
+++ b/features/todos/use-cases.ts
@@ -12,10 +12,12 @@
   .run(async ({ ctx, input }) => {
     const user = requireUser(ctx);
     await ctx.gate.authorize("todos.create");
-
-    return ctx.ports.uow.transaction(async (tx) =>
+    const todo = await ctx.ports.uow.transaction((tx) =>
       tx.todos.create({ userId: user.id, title: input.title }),
     );
+    const log = ctx.ports.logger.child({ requestId: ctx.requestId });
+    log.info("Todo created", { todoId: todo.id });
+    return todo;
   });

 export const deleteTodoUseCase = useCase

Run bun beignet doctor --strict, restart the app, and create a todo while signed in. Expect the todo to be saved and a Todo created log containing todoId, requestId, and the configured service. The log records identifiers without including the todo's title or request body.

Request logging

Use createLoggingHooks when you want HTTP lifecycle logs. The hook is framework-owned behavior, so it belongs in server/index.ts beside auth, devtools, CORS, and rate limiting.

import { createLoggingHooks } from "@beignet/core/server";

const requestLoggingHooks = createLoggingHooks<AppContext>({
  requestIdHeader: "x-request-id",
  onRequestEnd: ({
    ctx,
    req,
    requestInfo,
    res,
    durationMs,
    contract,
    error,
  }) => {
    if (!ctx) {
      return;
    }

    const log = ctx.ports.logger.child({
      requestId: ctx.requestId,
      contract: contract?.name,
    });

    const meta = {
      method: req.method,
      path: requestInfo.url.pathname,
      status: res.status,
      durationMs: Math.round(durationMs),
    };

    if (error) {
      log.error("Request failed", { ...meta, error });
      return;
    }

    log.info("Request completed", meta);
  },
});

Make sure the context blueprint and auth hooks add the request fields you want in logs, such as requestId, actor.id, tenant.id, or role. When createServer(...) has a trustedProxy policy, logging observers receive the same resolved requestInfo as the context factory, CSRF, and rate limiting. Beignet does not log requestInfo.clientIp automatically. Add it only when the application needs IP-based operational records and has an appropriate retention policy.

What to log

Good production logs are structured and sparse:

LocationLog
Hooksrequest start/end, auth failures, rate limit decisions
Use casesbusiness milestones and expected domain failures
Jobsdispatch, start, success, retry, failure
Providersconnection setup, teardown, external service errors

Avoid logging request bodies, passwords, tokens, cookies, full authorization headers, or unbounded objects. Prefer stable IDs and counts.

The Pino adapter redacts sensitive structured keys and high-confidence credential strings by default. It does not rewrite the log message itself, so do not interpolate secrets into message strings.

Use the shared redaction helpers for structured metadata that may include headers or provider payloads:

import { redactHeaders, redactValue } from "@beignet/core/ports";

log.info("Request received", {
  headers: redactHeaders(req.headers),
});

log.info("Provider payload", redactValue(payload));

Testing

Tests can use a no-op or captured logger:

import type { LoggerPort } from "@beignet/core/ports";

export function createTestLogger(): LoggerPort {
  const logger: LoggerPort = {
    trace: () => {},
    debug: () => {},
    info: () => {},
    warn: () => {},
    error: () => {},
    fatal: () => {},
    child: () => logger,
  };

  return logger;
}

Use a captured logger when the behavior under test is that a specific diagnostic was emitted. Otherwise, a no-op logger keeps tests quiet.