Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 14 additions & 137 deletions src/proxy/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@ import { getEnv } from "./env.ts";
import { getTraceContext } from "./tracing.ts";
import { AsyncLocalStorage } from "node:async_hooks";
import { PROXY_RUNTIME_VERSION } from "./version.ts";

// NOTE: Formatting utilities are INLINED below instead of imported from ../utils/logger/core.ts
// because the proxy Docker build only copies src/proxy/ and has no access to src/utils/.
// Keep these in sync with src/utils/logger/core.ts if changes are needed.
import {
ANSI,
colorize,
formatContextText,
formatTimestamp,
isRecord,
LEVEL_COLORS,
LEVEL_GLYPHS,
type LogLevelName,
padTag,
type SerializedError,
serializeError,
} from "#veryfront/utils/logger/core.ts";

/**
* Request context for proxy logging.
Expand Down Expand Up @@ -42,7 +51,7 @@ function getProxyRequestContext(): ProxyRequestContext | undefined {
return requestContextStore.getStore();
}

export type LogLevel = "debug" | "info" | "warn" | "error";
export type LogLevel = LogLevelName;

const LOG_LEVEL_ORDER: Record<LogLevel, number> = {
debug: 0,
Expand All @@ -60,138 +69,6 @@ const MIN_LOG_LEVEL: LogLevel = (() => {
return "info"; // Default: suppress debug logs
})();

// ============================================================================
// INLINED FORMATTING UTILITIES (from src/utils/logger/core.ts)
// These must be kept in sync with core.ts but cannot be imported due to
// Docker build constraints (proxy is built independently).
// ============================================================================

const TAG_WIDTH = 10;
const PREFIX_WIDTH = 23; // timestamp(8) + gap(2) + tag(10) + space(1) + glyph(1) + space(1)

const LEVEL_GLYPHS: Record<LogLevel, string> = {
debug: "·",
info: "●",
warn: "▲",
error: "✖",
};

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

const LEVEL_COLORS: Record<LogLevel, string> = {
debug: ANSI.gray,
info: ANSI.green,
warn: ANSI.yellow,
error: ANSI.red,
};

function padTag(tag: string): string {
if (tag.length >= TAG_WIDTH) return tag.slice(0, TAG_WIDTH);
return tag.padEnd(TAG_WIDTH, " ");
}

function formatTimestamp(date: Date = new Date()): string {
const pad = (value: number) => String(value).padStart(2, "0");
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}

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

function normalizeText(value: string): string {
return value.replace(/\s+/g, " ");
}

function truncateText(value: string, maxLength = 80): string {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength - 1)}…`;
}

function formatValue(value: unknown): string {
if (typeof value === "string") {
const trimmed = normalizeText(value);
return /\s/.test(trimmed) ? JSON.stringify(trimmed) : trimmed;
}
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (value === null) return "null";
if (value === undefined) return "undefined";

let text: string | undefined;
try {
text = JSON.stringify(value);
} catch (_) {
/* expected: non-serializable value */
text = String(value);
}

if (text === undefined) return "undefined";
return truncateText(normalizeText(text));
}

interface SerializedError {
name: string;
message: string;
stack?: string;
}

function formatErrorText(error: SerializedError | undefined): string {
if (error == null) return "";

const message = [error.name, error.message].join(": ");
return truncateText(normalizeText(message), 120);
}

function serializeError(error: unknown): SerializedError | undefined {
if (error == null) return undefined;

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

return { name: "UnknownError", message: String(error) };
}

function formatContextText(
context: Record<string, unknown>,
error: SerializedError | undefined,
enableColor: boolean,
): string {
const entries: string[] = [];
for (const [key, value] of Object.entries(context)) {
entries.push(`${key}=${formatValue(value)}`);
}

const errorText = formatErrorText(error);
if (errorText) entries.push(`err=${errorText}`);
if (entries.length === 0) return "";

const coloredText = colorize(entries.join(" "), ANSI.dim, enableColor);
return `\n${" ".repeat(PREFIX_WIDTH)}${coloredText}`;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

// ============================================================================
// END INLINED FORMATTING UTILITIES
// ============================================================================

function isTty(): boolean {
try {
if (typeof Deno !== "undefined" && typeof Deno.stdout?.isTerminal === "function") {
Expand Down