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.

Setup

Use the Pino provider for production logging:

bun add @beignet/core @beignet/provider-logger-pino pino
import { createNextServer } from "@beignet/next";
import { loggerPinoProvider } from "@beignet/provider-logger-pino";
import { appPorts } from "@/infra/app-ports";

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

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.

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;
}

Use child loggers for request or workflow fields that should appear on every line:

export async function publishPost(ctx: AppContext, input: PublishPostInput) {
  const log = ctx.ports.logger.child({
    requestId: ctx.requestId,
    postId: input.postId,
  });

  log.info("Publishing post");
  const post = await ctx.ports.posts.publish(input.postId);
  log.info("Post published", { slug: post.slug });

  return post;
}

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, 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: new URL(req.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 createContext and auth hooks add the request fields you want in logs, such as requestId, actor.id, tenant.id, or role.

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.

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.