All files / src logger.ts

73.43% Statements 47/64
75% Branches 48/64
72.72% Functions 8/11
77.58% Lines 45/58

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 19985x                   85x   118x 118x 78x     85x               85x                                       85x                                                                                                                 85x 85x               85x             85x     85x             1068x   2619x 9x   2610x 58x   2552x 202x   2350x                     85x 366x 2x 364x 199x   165x             85x 36x 1x 35x 24x   11x             85x 22x 1x 21x 20x   1x             85x 912x 1x 911x 825x   86x      
import { pino, type LoggerOptions } from "pino";
 
/**
 * Truncate an ID for safe logging.
 *
 * Shows first 4 characters + ".." + last 4 characters to avoid exposing full IDs
 * while maintaining identifiability.
 *
 * @example truncateId("9fd82b35-6789-abcd") → "9fd8..abcd"
 */
export function truncateId(id: string): string {
  // Runtime type guard for CodeQL - ensures string methods are safe
  Iif (typeof id !== "string") return String(id);
  if (id.length <= 10) return id;
  return id.substring(0, 4) + ".." + id.slice(-4);
}
 
const isTestEnv = process.env.NODE_ENV === "test" || process.env.JEST_WORKER_ID !== undefined;
 
/**
 * JSON log output mode.
 *
 * When true: Raw pino JSON output (NDJSON) for log aggregators (Loki, ELK, Datadog)
 * When false (default): Human-readable single-line format via pino-pretty
 */
export const LOG_JSON = process.env.LOG_JSON === "true";
 
/**
 * Log format pattern using nginx-style tokens.
 *
 * Available tokens:
 * - %time  - Timestamp [HH:MM:SS.mmm]
 * - %level - Log level (INFO, WARN, ERROR, DEBUG)
 * - %name  - Logger name (gitlab-mcp)
 * - %msg   - Log message with structured data
 *
 * Presets:
 * - "%msg" (minimal/default) - Message only, for daemonized environments where
 *   journald/systemd already provides timestamp, level, and process name
 * - "[%time] %level (%name): %msg" (full) - Complete format for standalone use
 *
 * @example LOG_FORMAT="%msg"
 * @example LOG_FORMAT="[%time] %level (%name): %msg"
 * @example LOG_FORMAT="%level: %msg"
 */
export const LOG_FORMAT = process.env.LOG_FORMAT ?? "%msg";
 
/**
 * Convert LOG_FORMAT tokens to pino-pretty messageFormat template.
 *
 * Transforms nginx-style tokens to pino-pretty placeholders:
 * - %time  → {time}
 * - %level → {levelLabel}
 * - %name  → {name}
 * - %msg   → {msg}
 */
function convertToPinoFormat(format: string): string {
  return format
    .replace(/%time/g, "{time}")
    .replace(/%level/g, "{levelLabel}")
    .replace(/%name/g, "{name}")
    .replace(/%msg/g, "{msg}");
}
 
/**
 * Determine which fields to include based on LOG_FORMAT tokens.
 */
function getIgnoredFields(format: string): string {
  const ignored: string[] = ["pid", "hostname"];
 
  if (!format.includes("%time")) ignored.push("time");
  if (!format.includes("%level")) ignored.push("level");
  if (!format.includes("%name")) ignored.push("name");
 
  return ignored.join(",");
}
 
/**
 * Build pino-pretty options based on LOG_FORMAT
 */
function buildPrettyOptions(format: string): Record<string, unknown> {
  const baseOptions = {
    destination: 2, // stderr - keeps stdout clean for CLI tools (list-tools --export)
  };
 
  const hasTime = format.includes("%time");
  const pinoFormat = convertToPinoFormat(format);
  const ignored = getIgnoredFields(format);
 
  // Minimal format (just %msg) - no colors, pure message output
  const isMinimal = format.trim() === "%msg";
 
  return {
    ...baseOptions,
    colorize: !isMinimal,
    translateTime: hasTime ? "HH:MM:ss.l" : false,
    ignore: ignored,
    messageFormat: pinoFormat,
    hideObject: true, // Hide the JSON object, use messageFormat only
  };
}
 
export const createLogger = (name?: string) => {
  const options: LoggerOptions = {
    name,
    level: process.env.LOG_LEVEL ?? "info",
  };
 
  // JSON mode: raw pino output (no pretty printing) for log aggregators
  // Plain mode: pino-pretty for human-readable output
  // Test mode: skip transport to avoid Jest worker thread leak
  Iif (!isTestEnv && !LOG_JSON) {
    options.transport = {
      target: "pino-pretty",
      options: buildPrettyOptions(LOG_FORMAT),
    };
  }
 
  return pino(options);
};
 
export const logger = createLogger("gitlab-mcp");
 
/**
 * Format data object as key=value pairs for plain text logging.
 * Handles nested objects by JSON stringifying them.
 */
function formatDataPairs(data: Record<string, unknown>): string {
  return Object.entries(data)
    .map(([k, v]) => {
      if (v instanceof Error) {
        return `${k}=${v.stack ?? v.message}`;
      }
      if (v === null || v === undefined) {
        return `${k}=${String(v)}`;
      }
      if (typeof v === "object") {
        return `${k}=${JSON.stringify(v)}`;
      }
      return `${k}=${String(v)}`;
    })
    .join(" ");
}
 
/**
 * Log at INFO level with optional structured data.
 *
 * JSON mode: Full structured object for log aggregators (Loki, ELK, Datadog)
 * Plain mode: Single-line with key=value pairs appended to message
 */
export function logInfo(message: string, data?: Record<string, unknown>): void {
  if (LOG_JSON) {
    logger.info(data ?? {}, message);
  } else if (data && Object.keys(data).length > 0) {
    logger.info(`${message} ${formatDataPairs(data)}`);
  } else {
    logger.info(message);
  }
}
 
/**
 * Log at WARN level with optional structured data.
 */
export function logWarn(message: string, data?: Record<string, unknown>): void {
  if (LOG_JSON) {
    logger.warn(data ?? {}, message);
  } else if (data && Object.keys(data).length > 0) {
    logger.warn(`${message} ${formatDataPairs(data)}`);
  } else {
    logger.warn(message);
  }
}
 
/**
 * Log at ERROR level with optional structured data.
 */
export function logError(message: string, data?: Record<string, unknown>): void {
  if (LOG_JSON) {
    logger.error(data ?? {}, message);
  } else if (data && Object.keys(data).length > 0) {
    logger.error(`${message} ${formatDataPairs(data)}`);
  } else {
    logger.error(message);
  }
}
 
/**
 * Log at DEBUG level with optional structured data.
 */
export function logDebug(message: string, data?: Record<string, unknown>): void {
  if (LOG_JSON) {
    logger.debug(data ?? {}, message);
  } else if (data && Object.keys(data).length > 0) {
    logger.debug(`${message} ${formatDataPairs(data)}`);
  } else {
    logger.debug(message);
  }
}