/** * @module cwApiLogger * * Axios interceptor-based logger that records every ConnectWise API * request to a JSONL (newline-delimited JSON) file for post-hoc analysis. * * Each line in the log file is a self-contained JSON object with: * - timestamp (ISO-8601) * - method, url, baseURL * - status (HTTP status or null on network error) * - durationMs (wall-clock time from request start → response/error) * - error (error code / message, if any) * - timeout (configured timeout in ms) * * Logging is **opt-in** — set the `LOG_CW_API` environment variable to * any truthy value to enable it. When enabled, each process start creates * a new timestamped file inside the `cw-api-logs/` directory: * * LOG_CW_API=1 bun run dev # uses cw-api-logs/.jsonl * bun run dev:log # shorthand (sets LOG_CW_API=1) * * Appends are non-blocking (fire-and-forget) to avoid slowing down * the actual API flow. * * Usage: * import { attachCwApiLogger } from "./modules/cw-utils/cwApiLogger"; * attachCwApiLogger(connectWiseApi); */ import { appendFile, mkdir } from "fs/promises"; import path from "path"; import type { AxiosInstance, InternalAxiosRequestConfig } from "axios"; const LOG_DIR = path.resolve(process.cwd(), "cw-api-logs"); /** Build a timestamped filename like `2026-03-02T14-30-05.123Z.jsonl` */ function buildLogPath(): string { const ts = new Date().toISOString().replace(/:/g, "-"); return path.join(LOG_DIR, `${ts}.jsonl`); } let LOG_PATH: string | null = null; // Symbol used to stash the start time on the request config const START_TIME = Symbol("cwLogStartTime"); interface TimedConfig extends InternalAxiosRequestConfig { [START_TIME]?: number; } export interface CwApiLogEntry { timestamp: string; method: string; url: string; baseURL: string; status: number | null; durationMs: number; error: string | null; timeout: number | undefined; } /** Write a single log entry (fire-and-forget). */ function writeEntry(entry: CwApiLogEntry): void { if (!LOG_PATH) return; appendFile(LOG_PATH, JSON.stringify(entry) + "\n").catch((err) => { // Swallow write errors — logging should never crash the app console.error("[cw-logger] failed to write log entry:", err.message); }); } /** * Attach request/response interceptors to an Axios instance to log * every CW API call with timing information. */ export function attachCwApiLogger(api: AxiosInstance): void { if (!process.env.LOG_CW_API) { return; } // Create the log directory and build a unique file path for this run LOG_PATH = buildLogPath(); mkdir(LOG_DIR, { recursive: true }).catch((err) => { console.error("[cw-logger] failed to create log directory:", err.message); }); // ---- Request interceptor: record start time -------------------------- api.interceptors.request.use((config: TimedConfig) => { config[START_TIME] = performance.now(); return config; }); // ---- Response interceptor: log successful calls ---------------------- api.interceptors.response.use( (response) => { const config = response.config as TimedConfig; const start = config[START_TIME] ?? performance.now(); const durationMs = Math.round(performance.now() - start); writeEntry({ timestamp: new Date().toISOString(), method: (config.method ?? "GET").toUpperCase(), url: config.url ?? "", baseURL: config.baseURL ?? "", status: response.status, durationMs, error: null, timeout: config.timeout, }); return response; }, // ---- Error interceptor: log failed calls ----------------------------- (err) => { const config = (err.config ?? {}) as TimedConfig; const start = config[START_TIME] ?? performance.now(); const durationMs = Math.round(performance.now() - start); writeEntry({ timestamp: new Date().toISOString(), method: (config.method ?? "GET").toUpperCase(), url: config.url ?? "", baseURL: config.baseURL ?? "", status: err.response?.status ?? null, durationMs, error: err.code ? `${err.code}: ${err.message}` : (err.message ?? "unknown"), timeout: config.timeout, }); return Promise.reject(err); }, ); console.log(`[cw-logger] logging CW API calls to ${LOG_PATH}`); } /** Returns the current log file path (or null if logging is disabled). */ export function getCwLogPath(): string | null { return LOG_PATH; }