Source: utils/logFormatter.js

/**
 * @module utils/logFormatter
 * @description Centralised log formatting with `.env`-driven configuration.
 *
 * ### Environment variables
 * | Variable          | Default  | Description                                    |
 * |-------------------|----------|------------------------------------------------|
 * | `LOG_LEVEL`       | `"info"` | Minimum severity: `debug` / `info` / `warn` / `error` |
 * | `LOG_DATE_FORMAT` | `"iso"`  | Timestamp format: `iso` / `utc` / `local` / `epoch`   |
 * | `LOG_TIMEZONE`    | system   | IANA timezone for `local` format               |
 * | `LOG_JSON`        | `"false"`| `"true"` to emit structured JSON lines         |
 *
 * ### Exports
 * - {@link formatTimestamp} — Produce a formatted timestamp string.
 * - {@link formatLogLine} — Format a complete log line for stdout.
 * - {@link shouldLog} — Check if a level should be printed.
 * - {@link LOG_LEVEL} — Configured minimum log level (numeric).
 */

// ─── Log levels ───────────────────────────────────────────────────────────────
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };

const configuredLevel = (process.env.LOG_LEVEL || "info").toLowerCase();
export const LOG_LEVEL = LEVELS[configuredLevel] ?? LEVELS.info;

/**
 * Returns true if the given level should be printed based on LOG_LEVEL.
 * @param {"debug"|"info"|"warn"|"error"} level
 */
export function shouldLog(level) {
  return (LEVELS[level] ?? LEVELS.info) >= LOG_LEVEL;
}

// ─── Timestamp formatting ─────────────────────────────────────────────────────
const dateFormat = (process.env.LOG_DATE_FORMAT || "iso").toLowerCase();
const timezone = process.env.LOG_TIMEZONE || undefined; // undefined = system default

/**
 * Produce a formatted timestamp string according to LOG_DATE_FORMAT.
 *
 * @param {Date} [date] — defaults to now
 * @returns {string}
 */
export function formatTimestamp(date) {
  const d = date || new Date();
  switch (dateFormat) {
    case "epoch":
      return String(d.getTime());
    case "utc":
      return d.toUTCString();
    case "local":
      try {
        return d.toLocaleString("en-US", {
          timeZone: timezone,
          year: "numeric", month: "2-digit", day: "2-digit",
          hour: "2-digit", minute: "2-digit", second: "2-digit",
          hour12: false, fractionalSecondDigits: 3,
        });
      } catch {
        // Invalid timezone — fall through to ISO
        return d.toISOString();
      }
    case "iso":
    default:
      return d.toISOString();
  }
}

// ─── Structured log line ──────────────────────────────────────────────────────
const jsonMode = (process.env.LOG_JSON || "false").toLowerCase() === "true";

/**
 * Format a complete log line for server stdout.
 *
 * When LOG_JSON=true, emits a single-line JSON object:
 *   {"ts":"...","level":"info","runId":"RUN-42","msg":"Starting crawl"}
 *
 * Otherwise emits the human-readable format:
 *   [2025-04-03T12:34:56.789Z] [INFO] [RUN-42] Starting crawl
 *
 * @param {"debug"|"info"|"warn"|"error"} level
 * @param {string} runId
 * @param {string} msg
 * @returns {string}
 */
export function formatLogLine(level, runId, msg) {
  const ts = formatTimestamp();
  if (jsonMode) {
    return JSON.stringify({ ts, level, runId: runId || undefined, msg });
  }
  const tag = level.toUpperCase().padEnd(5);
  const rid = runId ? ` [${runId}]` : "";
  return `[${ts}] [${tag}]${rid} ${msg}`;
}

/**
 * Emit a structured lifecycle event to stdout.
 *
 * When LOG_JSON=true, emits a JSON line with the event name and all props:
 *   {"ts":"...","event":"run.start","runId":"RUN-42","tests":5}
 *
 * When LOG_JSON=false, emits a human-readable line:
 *   [2025-04-03T12:34:56.789Z] [EVENT] run.start runId=RUN-42 tests=5
 *
 * Use this for machine-filterable lifecycle events (run start/end, browser
 * launch, pipeline stage transitions). Use `formatLogLine()` for free-form
 * human-readable messages.
 *
 * @param {string} event — semantic event name (e.g. `"run.start"`, `"browser.launched"`)
 * @param {Object} [props] — structured key-value pairs to include
 */
export function structuredLog(event, props = {}) {
  if (!shouldLog("info")) return;
  const ts = formatTimestamp();
  if (jsonMode) {
    console.log(JSON.stringify({ ts, event, ...props }));
  } else {
    const kvPairs = Object.entries(props)
      .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
      .join(" ");
    console.log(`[${ts}] [EVENT] ${event}${kvPairs ? " " + kvPairs : ""}`);
  }
}