659 lines
20 KiB
TypeScript
659 lines
20 KiB
TypeScript
/**
|
|
* DevLogger — Development-only comprehensive interaction logger.
|
|
*
|
|
* Captures the FULL lifecycle of every chat interaction:
|
|
* 1. User input + context assembly
|
|
* 2. API request (headers, body, model, params)
|
|
* 3. SSE stream chunks (raw + parsed)
|
|
* 4. Response parsing (thinking extraction, transcript cleanup)
|
|
* 5. DB writes (what was persisted)
|
|
* 6. Error states + recovery actions
|
|
*
|
|
* All data is held in memory and can be exported as JSON or viewed
|
|
* in the browser console via `window.__devLog`.
|
|
*
|
|
* Only active when import.meta.env.DEV is true.
|
|
*/
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────
|
|
|
|
export interface InteractionLog {
|
|
id: string;
|
|
chatId: number | null;
|
|
startedAt: string;
|
|
finishedAt: string | null;
|
|
durationMs: number | null;
|
|
status: "pending" | "streaming" | "complete" | "error" | "aborted";
|
|
|
|
// 1. User input
|
|
userInput: {
|
|
rawText: string;
|
|
charCount: number;
|
|
estimatedTokens: number;
|
|
};
|
|
|
|
// 2. Context sent to API
|
|
context: {
|
|
messageCount: number;
|
|
messages: Array<{ role: string; contentPreview: string; contentLength: number }>;
|
|
totalContextChars: number;
|
|
memoryEnabled: boolean;
|
|
agenticMode: boolean;
|
|
};
|
|
|
|
// 3. API request
|
|
request: {
|
|
url: string;
|
|
method: string;
|
|
model: string;
|
|
stream: boolean;
|
|
maxTokens: number;
|
|
stopSequences: string[];
|
|
headers: Record<string, string>;
|
|
bodySize: number;
|
|
fullBody: unknown;
|
|
};
|
|
|
|
// 4. API response
|
|
response: {
|
|
httpStatus: number | null;
|
|
httpStatusText: string | null;
|
|
contentType: string | null;
|
|
responseHeaders: Record<string, string>;
|
|
};
|
|
|
|
// 5. Streaming data
|
|
streaming: {
|
|
chunksReceived: number;
|
|
rawChunks: Array<{
|
|
index: number;
|
|
timestamp: string;
|
|
raw: string;
|
|
parsed: unknown | null;
|
|
deltaContent: string | null;
|
|
error: string | null;
|
|
}>;
|
|
totalDeltaChars: number;
|
|
tokenCount: number;
|
|
firstTokenAt: string | null;
|
|
lastTokenAt: string | null;
|
|
timeToFirstTokenMs: number | null;
|
|
};
|
|
|
|
// 6. Parsed result
|
|
parsed: {
|
|
rawContent: string;
|
|
visibleContent: string;
|
|
reasoningContent: string;
|
|
thinkingOpen: boolean;
|
|
isCorrupted: boolean;
|
|
transcriptLeakageDetected: boolean;
|
|
contentLength: number;
|
|
};
|
|
|
|
// 7. Usage stats from API
|
|
usage: {
|
|
promptTokens: number | null;
|
|
completionTokens: number | null;
|
|
totalTokens: number | null;
|
|
tokensPerSecond: number | null;
|
|
};
|
|
|
|
// 8. DB persistence
|
|
dbWrites: Array<{
|
|
timestamp: string;
|
|
operation: string;
|
|
table: string;
|
|
params: unknown[];
|
|
resultId: number | null;
|
|
}>;
|
|
|
|
// 9. Errors
|
|
errors: Array<{
|
|
timestamp: string;
|
|
phase: string;
|
|
message: string;
|
|
stack: string | null;
|
|
raw: unknown;
|
|
}>;
|
|
|
|
// 10. Sanitization / encoding diagnostics
|
|
sanitization: Array<{
|
|
timestamp: string;
|
|
stage: string;
|
|
changed: boolean;
|
|
repairedEncoding: boolean;
|
|
beforePreview: string;
|
|
afterPreview: string;
|
|
}>;
|
|
|
|
// 11. Timeline of phases
|
|
timeline: Array<{
|
|
timestamp: string;
|
|
phase: string;
|
|
detail: string;
|
|
}>;
|
|
}
|
|
|
|
// ─── Logger Class ────────────────────────────────────────────────
|
|
|
|
const IS_DEV = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV);
|
|
const MAX_LOGS = 50;
|
|
|
|
class DevLogger {
|
|
private interactions: InteractionLog[] = [];
|
|
private current: InteractionLog | null = null;
|
|
private enabled: boolean;
|
|
private startTime: number = 0;
|
|
|
|
constructor() {
|
|
this.enabled = IS_DEV;
|
|
if (this.enabled && typeof window !== "undefined") {
|
|
(window as unknown as Record<string, unknown>).__devLog = this;
|
|
console.log(
|
|
"%c[DevLogger] 🔍 Development logging ACTIVE — access via window.__devLog",
|
|
"color: #22c55e; font-weight: bold; font-size: 13px",
|
|
);
|
|
console.log(
|
|
"%c .current → current interaction\n .interactions → all logged interactions\n .export() → JSON export\n .dump() → console table\n .dumpLast() → last interaction detail\n .clear() → reset",
|
|
"color: #999; font-size: 11px",
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Interaction Lifecycle ──
|
|
|
|
startInteraction(chatId: number | null, userText: string, opts: {
|
|
memoryEnabled: boolean;
|
|
agenticMode: boolean;
|
|
}): string {
|
|
if (!this.enabled) return "";
|
|
|
|
const id = `int_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
this.startTime = performance.now();
|
|
|
|
this.current = {
|
|
id,
|
|
chatId,
|
|
startedAt: new Date().toISOString(),
|
|
finishedAt: null,
|
|
durationMs: null,
|
|
status: "pending",
|
|
userInput: {
|
|
rawText: userText,
|
|
charCount: userText.length,
|
|
estimatedTokens: Math.ceil(userText.length / 4),
|
|
},
|
|
context: {
|
|
messageCount: 0,
|
|
messages: [],
|
|
totalContextChars: 0,
|
|
memoryEnabled: opts.memoryEnabled,
|
|
agenticMode: opts.agenticMode,
|
|
},
|
|
request: {
|
|
url: "",
|
|
method: "POST",
|
|
model: "",
|
|
stream: true,
|
|
maxTokens: 0,
|
|
stopSequences: [],
|
|
headers: {},
|
|
bodySize: 0,
|
|
fullBody: null,
|
|
},
|
|
response: {
|
|
httpStatus: null,
|
|
httpStatusText: null,
|
|
contentType: null,
|
|
responseHeaders: {},
|
|
},
|
|
streaming: {
|
|
chunksReceived: 0,
|
|
rawChunks: [],
|
|
totalDeltaChars: 0,
|
|
tokenCount: 0,
|
|
firstTokenAt: null,
|
|
lastTokenAt: null,
|
|
timeToFirstTokenMs: null,
|
|
},
|
|
parsed: {
|
|
rawContent: "",
|
|
visibleContent: "",
|
|
reasoningContent: "",
|
|
thinkingOpen: false,
|
|
isCorrupted: false,
|
|
transcriptLeakageDetected: false,
|
|
contentLength: 0,
|
|
},
|
|
usage: {
|
|
promptTokens: null,
|
|
completionTokens: null,
|
|
totalTokens: null,
|
|
tokensPerSecond: null,
|
|
},
|
|
dbWrites: [],
|
|
errors: [],
|
|
sanitization: [],
|
|
timeline: [],
|
|
};
|
|
|
|
this.addTimeline("init", `Interaction started — chatId=${chatId}, text="${userText.slice(0, 60)}…"`);
|
|
this.logPhase("📝 User Input", { text: userText.slice(0, 200), chars: userText.length });
|
|
return id;
|
|
}
|
|
|
|
logContext(messages: Array<{ role: string; content: string }>) {
|
|
if (!this.enabled || !this.current) return;
|
|
|
|
const ctx = this.current.context;
|
|
ctx.messageCount = messages.length;
|
|
ctx.messages = messages.map((m) => ({
|
|
role: m.role,
|
|
contentPreview: m.content.slice(0, 100),
|
|
contentLength: m.content.length,
|
|
}));
|
|
ctx.totalContextChars = messages.reduce((sum, m) => sum + m.content.length, 0);
|
|
|
|
this.addTimeline("context", `${messages.length} messages, ${ctx.totalContextChars} chars total`);
|
|
this.logPhase("📦 Context Assembly", {
|
|
messageCount: messages.length,
|
|
totalChars: ctx.totalContextChars,
|
|
roles: messages.map((m) => m.role).join(", "),
|
|
messages: ctx.messages,
|
|
});
|
|
}
|
|
|
|
logRequest(url: string, body: unknown, headers: Record<string, string>) {
|
|
if (!this.enabled || !this.current) return;
|
|
|
|
const bodyStr = JSON.stringify(body);
|
|
const req = this.current.request;
|
|
req.url = url;
|
|
req.fullBody = body;
|
|
req.bodySize = bodyStr.length;
|
|
req.headers = sanitizeHeaders(headers);
|
|
|
|
if (typeof body === "object" && body !== null) {
|
|
const b = body as Record<string, unknown>;
|
|
req.model = String(b.model ?? "");
|
|
req.stream = Boolean(b.stream);
|
|
req.maxTokens = Number(b.max_tokens ?? 0);
|
|
req.stopSequences = Array.isArray(b.stop) ? (b.stop as string[]) : [];
|
|
}
|
|
|
|
this.addTimeline("request", `POST ${url} — model=${req.model}, stream=${req.stream}, body=${bodyStr.length}B`);
|
|
this.logPhase("🚀 API Request", {
|
|
url,
|
|
model: req.model,
|
|
stream: req.stream,
|
|
maxTokens: req.maxTokens,
|
|
bodySize: bodyStr.length,
|
|
stopSequences: req.stopSequences,
|
|
fullBody: body,
|
|
});
|
|
}
|
|
|
|
logResponse(res: Response) {
|
|
if (!this.enabled || !this.current) return;
|
|
|
|
const resp = this.current.response;
|
|
resp.httpStatus = res.status;
|
|
resp.httpStatusText = res.statusText;
|
|
resp.contentType = res.headers.get("content-type");
|
|
res.headers.forEach((value, key) => {
|
|
resp.responseHeaders[key] = value;
|
|
});
|
|
|
|
this.addTimeline("response", `HTTP ${res.status} ${res.statusText} — ${resp.contentType}`);
|
|
this.logPhase("📡 API Response", {
|
|
status: res.status,
|
|
statusText: res.statusText,
|
|
contentType: resp.contentType,
|
|
headers: resp.responseHeaders,
|
|
});
|
|
}
|
|
|
|
logStreamChunk(raw: string, parsed: unknown | null, deltaContent: string | null, error: string | null = null) {
|
|
if (!this.enabled || !this.current) return;
|
|
|
|
const s = this.current.streaming;
|
|
const now = new Date().toISOString();
|
|
|
|
s.chunksReceived++;
|
|
|
|
// Store first 100 chunks in full + every 50th after that for inspection
|
|
if (s.chunksReceived <= 100 || s.chunksReceived % 50 === 0) {
|
|
s.rawChunks.push({
|
|
index: s.chunksReceived,
|
|
timestamp: now,
|
|
raw: raw.slice(0, 500),
|
|
parsed,
|
|
deltaContent,
|
|
error,
|
|
});
|
|
}
|
|
|
|
if (deltaContent) {
|
|
s.totalDeltaChars += deltaContent.length;
|
|
s.tokenCount++;
|
|
s.lastTokenAt = now;
|
|
|
|
if (!s.firstTokenAt) {
|
|
s.firstTokenAt = now;
|
|
s.timeToFirstTokenMs = performance.now() - this.startTime;
|
|
this.addTimeline("first_token", `TTFT=${s.timeToFirstTokenMs.toFixed(0)}ms, delta="${deltaContent}"`);
|
|
}
|
|
}
|
|
|
|
if (error) {
|
|
this.current.errors.push({ timestamp: now, phase: "stream_chunk", message: error, stack: null, raw });
|
|
}
|
|
|
|
// Log first 3, then every 50th chunk to console
|
|
if (s.chunksReceived <= 3 || s.chunksReceived % 50 === 0) {
|
|
console.debug(
|
|
`%c[DevLogger] 🔄 Chunk #${s.chunksReceived}`,
|
|
"color: #60a5fa",
|
|
{ delta: deltaContent, totalChars: s.totalDeltaChars, raw: raw.slice(0, 120) },
|
|
);
|
|
}
|
|
}
|
|
|
|
logNonStreamResponse(data: unknown) {
|
|
if (!this.enabled || !this.current) return;
|
|
|
|
this.addTimeline("non_stream_response", "Full response received (non-streaming)");
|
|
this.logPhase("📩 Non-Stream Response", data);
|
|
}
|
|
|
|
logParsedResult(result: {
|
|
rawContent: string;
|
|
visibleContent: string;
|
|
reasoningContent: string;
|
|
thinkingOpen: boolean;
|
|
isCorrupted: boolean;
|
|
transcriptLeakageDetected: boolean;
|
|
}) {
|
|
if (!this.enabled || !this.current) return;
|
|
|
|
const p = this.current.parsed;
|
|
Object.assign(p, result);
|
|
p.contentLength = result.visibleContent.length;
|
|
|
|
this.addTimeline("parsed", `visible=${result.visibleContent.length}ch, reasoning=${result.reasoningContent.length}ch, corrupted=${result.isCorrupted}`);
|
|
|
|
const logStyle = result.isCorrupted
|
|
? "%c[DevLogger] ⚠️ PARSED RESULT (CORRUPTED)"
|
|
: "%c[DevLogger] ✅ Parsed Result";
|
|
const logColor = result.isCorrupted ? "color: #ef4444; font-weight: bold" : "color: #22c55e";
|
|
|
|
console.groupCollapsed(logStyle, logColor);
|
|
console.log("Raw content:", result.rawContent);
|
|
console.log("Visible content:", result.visibleContent);
|
|
console.log("Reasoning:", result.reasoningContent || "(none)");
|
|
console.log("Thinking still open:", result.thinkingOpen);
|
|
console.log("Corrupted:", result.isCorrupted);
|
|
console.log("Transcript leak:", result.transcriptLeakageDetected);
|
|
console.groupEnd();
|
|
}
|
|
|
|
logUsage(usage: {
|
|
prompt_tokens?: number;
|
|
completion_tokens?: number;
|
|
total_tokens?: number;
|
|
tokens_per_second?: number;
|
|
} | null) {
|
|
if (!this.enabled || !this.current || !usage) return;
|
|
|
|
this.current.usage = {
|
|
promptTokens: usage.prompt_tokens ?? null,
|
|
completionTokens: usage.completion_tokens ?? null,
|
|
totalTokens: usage.total_tokens ?? null,
|
|
tokensPerSecond: usage.tokens_per_second ?? null,
|
|
};
|
|
|
|
this.addTimeline("usage", `prompt=${usage.prompt_tokens}, completion=${usage.completion_tokens}, tps=${usage.tokens_per_second?.toFixed(1)}`);
|
|
this.logPhase("📊 Usage Stats", usage);
|
|
}
|
|
|
|
logDBWrite(operation: string, table: string, params: unknown[], resultId: number | null = null) {
|
|
if (!this.enabled || !this.current) return;
|
|
|
|
this.current.dbWrites.push({
|
|
timestamp: new Date().toISOString(),
|
|
operation,
|
|
table,
|
|
params: params.map((p) => typeof p === "string" && p.length > 200 ? p.slice(0, 200) + "…" : p),
|
|
resultId,
|
|
});
|
|
|
|
this.addTimeline("db_write", `${operation} → ${table}${resultId ? ` (id=${resultId})` : ""}`);
|
|
console.debug(`%c[DevLogger] 💾 DB ${operation}`, "color: #a855f7", { table, params: params.map((p) => typeof p === "string" && p.length > 100 ? p.slice(0, 100) + "…" : p), resultId });
|
|
}
|
|
|
|
logSanitization(stage: string, payload: {
|
|
before: string;
|
|
after: string;
|
|
changed: boolean;
|
|
repairedEncoding?: boolean;
|
|
}) {
|
|
if (!this.enabled || !this.current) return;
|
|
|
|
this.current.sanitization.push({
|
|
timestamp: new Date().toISOString(),
|
|
stage,
|
|
changed: payload.changed,
|
|
repairedEncoding: Boolean(payload.repairedEncoding),
|
|
beforePreview: payload.before.slice(0, 240),
|
|
afterPreview: payload.after.slice(0, 240),
|
|
});
|
|
|
|
if (payload.changed || payload.repairedEncoding) {
|
|
this.addTimeline(
|
|
"sanitize",
|
|
`${stage}: changed=${payload.changed} repairedEncoding=${Boolean(payload.repairedEncoding)}`,
|
|
);
|
|
console.warn(`%c[DevLogger] 🧼 Sanitization @ ${stage}`, "color: #f59e0b; font-weight: bold", {
|
|
repairedEncoding: Boolean(payload.repairedEncoding),
|
|
before: payload.before,
|
|
after: payload.after,
|
|
});
|
|
}
|
|
}
|
|
|
|
logError(phase: string, error: unknown) {
|
|
if (!this.enabled || !this.current) return;
|
|
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
this.current.errors.push({
|
|
timestamp: new Date().toISOString(),
|
|
phase,
|
|
message: err.message,
|
|
stack: err.stack ?? null,
|
|
raw: error,
|
|
});
|
|
this.current.status = "error";
|
|
|
|
this.addTimeline("error", `[${phase}] ${err.message}`);
|
|
console.error(`%c[DevLogger] ❌ Error in ${phase}`, "color: #ef4444; font-weight: bold", err);
|
|
}
|
|
|
|
logAbort(partialContent: string) {
|
|
if (!this.enabled || !this.current) return;
|
|
|
|
this.current.status = "aborted";
|
|
this.addTimeline("abort", `User aborted — partial content: ${partialContent.length} chars`);
|
|
this.logPhase("🛑 Aborted", { partialContentLength: partialContent.length, preview: partialContent.slice(0, 200) });
|
|
}
|
|
|
|
finishInteraction() {
|
|
if (!this.enabled || !this.current) return;
|
|
|
|
const elapsed = performance.now() - this.startTime;
|
|
this.current.durationMs = Math.round(elapsed);
|
|
this.current.finishedAt = new Date().toISOString();
|
|
if (this.current.status === "pending" || this.current.status === "streaming") {
|
|
this.current.status = "complete";
|
|
}
|
|
|
|
this.addTimeline("finish", `Duration=${elapsed.toFixed(0)}ms, status=${this.current.status}`);
|
|
|
|
// Summary console output
|
|
const c = this.current;
|
|
console.groupCollapsed(
|
|
`%c[DevLogger] 🏁 Interaction ${c.id} — ${c.status} in ${elapsed.toFixed(0)}ms`,
|
|
c.status === "error" ? "color: #ef4444; font-weight: bold" : "color: #22c55e; font-weight: bold",
|
|
);
|
|
console.table({
|
|
"User Input": `${c.userInput.charCount} chars`,
|
|
"Context Messages": c.context.messageCount,
|
|
"Context Chars": c.context.totalContextChars,
|
|
"Model": c.request.model,
|
|
"Stream": c.request.stream,
|
|
"HTTP Status": c.response.httpStatus,
|
|
"Chunks Received": c.streaming.chunksReceived,
|
|
"TTFT (ms)": c.streaming.timeToFirstTokenMs?.toFixed(0) ?? "n/a",
|
|
"Stream Tokens": c.streaming.tokenCount,
|
|
"Stream Chars": c.streaming.totalDeltaChars,
|
|
"Final Content": `${c.parsed.contentLength} chars`,
|
|
"Reasoning": c.parsed.reasoningContent ? `${c.parsed.reasoningContent.length} chars` : "none",
|
|
"Corrupted": c.parsed.isCorrupted,
|
|
"Prompt Tokens": c.usage.promptTokens,
|
|
"Completion Tokens": c.usage.completionTokens,
|
|
"TPS": c.usage.tokensPerSecond?.toFixed(1) ?? "n/a",
|
|
"DB Writes": c.dbWrites.length,
|
|
"Sanitizations": c.sanitization.length,
|
|
"Errors": c.errors.length,
|
|
"Duration (ms)": elapsed.toFixed(0),
|
|
});
|
|
if (c.errors.length > 0) {
|
|
console.warn("Errors:", c.errors);
|
|
}
|
|
if (c.parsed.isCorrupted) {
|
|
console.warn("⚠️ CORRUPTED RESPONSE — raw content:", c.parsed.rawContent);
|
|
}
|
|
// Log response content comparison for debugging "response 1" issue
|
|
if (c.parsed.visibleContent.trim().length <= 5) {
|
|
console.warn(
|
|
"⚠️ SUSPICIOUSLY SHORT RESPONSE:",
|
|
{
|
|
rawFromStream: c.streaming.totalDeltaChars,
|
|
rawContent: c.parsed.rawContent,
|
|
visibleContent: c.parsed.visibleContent,
|
|
allChunks: c.streaming.rawChunks,
|
|
},
|
|
);
|
|
}
|
|
console.groupEnd();
|
|
|
|
// Archive
|
|
this.interactions.push(this.current);
|
|
if (this.interactions.length > MAX_LOGS) {
|
|
this.interactions = this.interactions.slice(-MAX_LOGS);
|
|
}
|
|
this.current = null;
|
|
}
|
|
|
|
// ── Public Inspection API ──
|
|
|
|
/** Export all logs as JSON (for file download / clipboard). */
|
|
export(): string {
|
|
return JSON.stringify({
|
|
exportedAt: new Date().toISOString(),
|
|
interactionCount: this.interactions.length,
|
|
interactions: this.interactions,
|
|
}, null, 2);
|
|
}
|
|
|
|
/** Console table of all interactions. */
|
|
dump() {
|
|
if (this.interactions.length === 0) {
|
|
console.log("[DevLogger] No interactions recorded yet.");
|
|
return;
|
|
}
|
|
console.table(
|
|
this.interactions.map((i) => ({
|
|
id: i.id,
|
|
chatId: i.chatId,
|
|
status: i.status,
|
|
model: i.request.model,
|
|
userInput: i.userInput.rawText.slice(0, 40),
|
|
responseLen: i.parsed.contentLength,
|
|
corrupted: i.parsed.isCorrupted,
|
|
chunks: i.streaming.chunksReceived,
|
|
ttft: i.streaming.timeToFirstTokenMs?.toFixed(0),
|
|
tps: i.usage.tokensPerSecond?.toFixed(1),
|
|
durationMs: i.durationMs,
|
|
errors: i.errors.length,
|
|
})),
|
|
);
|
|
}
|
|
|
|
/** Detailed dump of the last interaction. */
|
|
dumpLast() {
|
|
const last = this.interactions[this.interactions.length - 1];
|
|
if (!last) {
|
|
console.log("[DevLogger] No interactions recorded yet.");
|
|
return;
|
|
}
|
|
console.log(`[DevLogger] Last interaction: ${last.id}`);
|
|
console.log("Timeline:");
|
|
last.timeline.forEach((t) => console.log(` ${t.timestamp} [${t.phase}] ${t.detail}`));
|
|
console.log("\nFull data:", last);
|
|
}
|
|
|
|
/** Return the latest interaction, optionally filtered by chatId. */
|
|
getLatestInteraction(chatId?: number | null): InteractionLog | null {
|
|
if (this.interactions.length === 0) return null;
|
|
if (chatId === undefined) return this.interactions[this.interactions.length - 1] ?? null;
|
|
|
|
for (let index = this.interactions.length - 1; index >= 0; index -= 1) {
|
|
const candidate = this.interactions[index];
|
|
if (candidate.chatId === chatId) return candidate;
|
|
}
|
|
|
|
return this.interactions[this.interactions.length - 1] ?? null;
|
|
}
|
|
|
|
/** Clear all logs. */
|
|
clear() {
|
|
this.interactions = [];
|
|
this.current = null;
|
|
console.log("[DevLogger] Cleared all logs.");
|
|
}
|
|
|
|
// ── Internal helpers ──
|
|
|
|
private addTimeline(phase: string, detail: string) {
|
|
this.current?.timeline.push({
|
|
timestamp: new Date().toISOString(),
|
|
phase,
|
|
detail,
|
|
});
|
|
}
|
|
|
|
private logPhase(label: string, data: unknown) {
|
|
console.groupCollapsed(`%c[DevLogger] ${label}`, "color: #60a5fa; font-weight: bold");
|
|
console.log(data);
|
|
console.groupEnd();
|
|
}
|
|
}
|
|
|
|
// ── Helpers ──
|
|
|
|
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
|
|
const safe = { ...headers };
|
|
// Mask authorization tokens
|
|
for (const key of Object.keys(safe)) {
|
|
if (key.toLowerCase().includes("auth") || key.toLowerCase().includes("key")) {
|
|
safe[key] = safe[key].slice(0, 10) + "…[MASKED]";
|
|
}
|
|
}
|
|
return safe;
|
|
}
|
|
|
|
// ── Singleton Export ──
|
|
|
|
export const devLog = new DevLogger();
|