IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

logger.ts

Buka raw
import "server-only";

import { randomUUID } from "node:crypto";
import { appendFile, mkdir, rename, stat } from "node:fs/promises";
import path from "node:path";
import { serverEnv } from "@/lib/server/env";
import type {
  LogLevel,
  LogLayer,
  LogStatus,
  StructuredLogEntry,
} from "@/lib/shared/contracts/logging";

type ServerLogInput = {
  level?: LogLevel;
  layer: LogLayer;
  action: string;
  status?: LogStatus;
  durationMs?: number;
  requestId?: string;
  message?: string;
  meta?: Record<string, unknown>;
  error?: unknown;
  runtime?: "server" | "client";
};

type ServerTimingOptions = {
  level?: LogLevel;
  layer: LogLayer;
  action: string;
  requestId?: string;
  meta?: Record<string, unknown>;
  onSuccessMeta?: (result: unknown) => Record<string, unknown> | undefined;
  onErrorMeta?: (error: unknown) => Record<string, unknown> | undefined;
};

const levelRank: Record<LogLevel, number> = {
  debug: 10,
  info: 20,
  warn: 30,
  error: 40,
};

const ANSI = {
  reset: "\x1b[0m",
  red: "\x1b[31m",
  green: "\x1b[32m",
  yellow: "\x1b[33m",
  cyan: "\x1b[36m",
  gray: "\x1b[90m",
} as const;

// Lines logged together are appended together: one file write per burst, not
// one open/write/close per line queued behind each other under load. The file
// rotates past LOG_FILE_MAX_BYTES, keeping the previous one as .1, so it cannot
// fill the disk of a small home server.
const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;

let writeQueue: Promise<void> = Promise.resolve();
let pendingLines: string[] = [];
let logFileBytes: number | null = null;
let logDirectoryReady = false;

function getServerLogLevel(): LogLevel {
  const raw = serverEnv.LOG_LEVEL;
  if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") {
    return raw;
  }

  return "info";
}

function shouldLog(level: LogLevel) {
  return levelRank[level] >= levelRank[getServerLogLevel()];
}

function canUseColor() {
  if (process.env.NO_COLOR === "1" || process.env.NO_COLOR === "true") return false;
  return Boolean(process.stdout?.isTTY);
}

function colorize(text: string, color: string) {
  if (!canUseColor()) return text;
  return `${color}${text}${ANSI.reset}`;
}

function safeStringify(value: unknown) {
  try {
    return JSON.stringify(value);
  } catch {
    return '"[unserializable]"';
  }
}

function statusGlyph(entry: StructuredLogEntry) {
  if (entry.status === "success") return colorize("✓", ANSI.green);
  if (entry.status === "error" || entry.level === "error") return colorize("x", ANSI.red);
  if (entry.level === "warn") return colorize("!", ANSI.yellow);
  if (entry.status === "start") return colorize(">", ANSI.cyan);
  return colorize("•", ANSI.gray);
}

function formatConsoleLine(entry: StructuredLogEntry) {
  const duration = typeof entry.durationMs === "number" ? ` ${entry.durationMs}ms` : "";
  const request = entry.requestId ? ` req=${entry.requestId}` : "";
  const message = entry.message ? ` msg=${entry.message}` : "";
  const meta = entry.meta ? ` meta=${safeStringify(entry.meta)}` : "";
  const error = entry.error?.message ? ` err=${entry.error.message}` : "";

  return `${statusGlyph(entry)} ${entry.timestamp} ${entry.layer}.${entry.action} ${entry.status.toUpperCase()}${duration}${request}${message}${meta}${error}`;
}

function serializeError(error: unknown) {
  if (!error) return undefined;

  if (error instanceof Error) {
    return {
      name: error.name,
      message: error.message,
      stack: error.stack,
    };
  }

  return {
    message: typeof error === "string" ? error : JSON.stringify(error),
  };
}

function getLogFilePath() {
  const filePath = serverEnv.LOG_FILE_PATH || "logs/home-server.log";
  return path.isAbsolute(filePath)
    ? filePath
    : path.resolve(process.cwd(), filePath);
}

async function ensureLogDirectory() {
  if (logDirectoryReady) return;

  await mkdir(path.dirname(getLogFilePath()), { recursive: true });
  logDirectoryReady = true;
}

function writeConsole(entry: StructuredLogEntry) {
  const line = formatConsoleLine(entry);

  if (entry.level === "error") {
    console.error(line);
    return;
  }

  if (entry.level === "warn") {
    console.warn(line);
    return;
  }

  console.log(line);
}

async function readFileSize(filePath: string) {
  try {
    return (await stat(filePath)).size;
  } catch {
    return 0;
  }
}

async function flushPendingLines() {
  const chunk = pendingLines.join("");
  pendingLines = [];
  const chunkBytes = Buffer.byteLength(chunk, "utf8");
  const filePath = getLogFilePath();

  await ensureLogDirectory();
  logFileBytes ??= await readFileSize(filePath);
  if (logFileBytes > 0 && logFileBytes + chunkBytes > LOG_FILE_MAX_BYTES) {
    await rename(filePath, `${filePath}.1`);
    logFileBytes = 0;
  }
  await appendFile(filePath, chunk, "utf8");
  logFileBytes += chunkBytes;
}

function reportFileWriteFailure(error: unknown) {
  // The size is unknown after a failed write or rotation: read it again next time.
  logFileBytes = null;
  const fallback: StructuredLogEntry = {
    timestamp: new Date().toISOString(),
    runtime: "server",
    level: "error",
    layer: "system",
    action: "log.file.write",
    status: "error",
    message: "Failed writing structured log to file",
    error: serializeError(error),
  };

  console.error(JSON.stringify(fallback));
}

function writeToFile(entry: StructuredLogEntry) {
  if (process.env.NODE_ENV === "test") return;
  if (serverEnv.LOG_TO_FILE === false) return;

  pendingLines.push(`${JSON.stringify(entry)}\n`);
  // The first line of a batch schedules its flush; later ones join the batch.
  if (pendingLines.length === 1) {
    writeQueue = writeQueue.then(flushPendingLines).catch(reportFileWriteFailure);
  }
}

export function logServerAction(input: ServerLogInput) {
  const level = input.level ?? "info";
  if (!shouldLog(level)) return;

  const entry: StructuredLogEntry = {
    timestamp: new Date().toISOString(),
    runtime: input.runtime ?? "server",
    level,
    layer: input.layer,
    action: input.action,
    status: input.status ?? "info",
    durationMs: input.durationMs,
    requestId: input.requestId,
    message: input.message,
    meta: input.meta,
    error: serializeError(input.error),
  };

  writeConsole(entry);
  writeToFile(entry);
}

export async function withServerTiming<T>(
  options: ServerTimingOptions,
  fn: () => Promise<T>,
): Promise<T> {
  const startedAt = performance.now();

  logServerAction({
    level: options.level ?? "info",
    layer: options.layer,
    action: options.action,
    status: "start",
    requestId: options.requestId,
    meta: options.meta,
  });

  try {
    const result = await fn();

    logServerAction({
      level: options.level ?? "info",
      layer: options.layer,
      action: options.action,
      status: "success",
      requestId: options.requestId,
      durationMs: Number((performance.now() - startedAt).toFixed(2)),
      meta: {
        ...options.meta,
        ...(options.onSuccessMeta ? options.onSuccessMeta(result) : {}),
      },
    });

    return result;
  } catch (error) {
    logServerAction({
      level: "error",
      layer: options.layer,
      action: options.action,
      status: "error",
      requestId: options.requestId,
      durationMs: Number((performance.now() - startedAt).toFixed(2)),
      meta: {
        ...options.meta,
        ...(options.onErrorMeta ? options.onErrorMeta(error) : {}),
      },
      error,
    });

    throw error;
  }
}

export function createRequestId() {
  return randomUUID();
}

export async function flushServerLogsForTests() {
  await writeQueue;
}
byte
7.636
baris
291
Jenis
ts
Commit terakhir
16 Oktober 2026