import { Platform } from "react-native"; import * as Updates from "expo-updates"; import Constants from "expo-constants"; import { AS } from "@/storageControl"; const STORAGE_KEY = "voicepeakDebugLogs"; const RETENTION_MILLISECONDS = 7 * 24 * 60 * 60 * 1000; const MAX_LOG_ENTRIES = 5000; export type VoicepeakDebugLogStatus = "attempting" | "success" | "error"; export type VoicepeakDebugLogEntry = { id: string; batchId?: string; createdAt: string; updatedAt: string; status: VoicepeakDebugLogStatus; text: string; textLength: number; codePointCount: number; format: "mp3" | "wav"; baseUrl: string; chunkIndex?: number; totalChunks?: number; attemptNumber?: number; forceRequested?: boolean; httpStatus?: number; errorCode?: string; requestId?: string; cacheStatus?: string; queueWaitMilliseconds?: number; queueDepth?: number; durationMilliseconds?: number; responseBytes?: number; warning?: string; error?: string; platform: string; platformVersion: string; appVersion?: string; runtimeVersion?: string; }; type NewVoicepeakDebugLog = Pick< VoicepeakDebugLogEntry, "text" | "format" | "baseUrl" > & Partial< Pick< VoicepeakDebugLogEntry, | "batchId" | "chunkIndex" | "totalChunks" | "attemptNumber" | "forceRequested" > >; type VoicepeakDebugLogResult = Partial< Pick< VoicepeakDebugLogEntry, | "httpStatus" | "errorCode" | "requestId" | "cacheStatus" | "queueWaitMilliseconds" | "queueDepth" | "durationMilliseconds" | "responseBytes" | "warning" | "error" > > & { status: Exclude; }; let transactionQueue = Promise.resolve(); const serializeError = (error: unknown) => { const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error); return message.slice(0, 2000); }; const readLogs = async (): Promise => { try { const stored = await AS.getItem(STORAGE_KEY); return Array.isArray(stored) ? stored : []; } catch { return []; } }; const pruneLogs = (logs: VoicepeakDebugLogEntry[], now = Date.now()) => logs .filter((log) => { const timestamp = Date.parse(log.createdAt); return Number.isFinite(timestamp) && now - timestamp < RETENTION_MILLISECONDS; }) .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) .slice(0, MAX_LOG_ENTRIES); const runTransaction = (operation: () => Promise): Promise => { const result = transactionQueue.then(operation, operation); transactionQueue = result.then( () => undefined, () => undefined ); return result; }; export const createVoicepeakDebugLog = async ( input: NewVoicepeakDebugLog ) => { const now = new Date().toISOString(); const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const entry: VoicepeakDebugLogEntry = { id, batchId: input.batchId, createdAt: now, updatedAt: now, status: "attempting", text: input.text, textLength: input.text.length, codePointCount: Array.from(input.text).length, format: input.format, baseUrl: input.baseUrl, chunkIndex: input.chunkIndex, totalChunks: input.totalChunks, attemptNumber: input.attemptNumber, forceRequested: input.forceRequested, platform: Platform.OS, platformVersion: String(Platform.Version), appVersion: Constants.expoConfig?.version, runtimeVersion: Updates.runtimeVersion ?? undefined, }; await runTransaction(async () => { const logs = pruneLogs([entry, ...(await readLogs())]); await AS.setItem(STORAGE_KEY, logs); }); return id; }; export const completeVoicepeakDebugLog = async ( id: string, result: VoicepeakDebugLogResult ) => { await runTransaction(async () => { const logs = await readLogs(); const updatedAt = new Date().toISOString(); const nextLogs = logs.map((log) => log.id === id ? { ...log, ...result, warning: result.warning ? serializeError(result.warning) : undefined, error: result.error ? serializeError(result.error) : undefined, updatedAt, } : log ); await AS.setItem(STORAGE_KEY, pruneLogs(nextLogs)); }); }; export const getVoicepeakDebugLogs = async () => runTransaction(async () => { const logs = pruneLogs(await readLogs()); await AS.setItem(STORAGE_KEY, logs); return logs; }); export const clearVoicepeakDebugLogs = async () => runTransaction(async () => { await AS.removeItem(STORAGE_KEY).catch(() => undefined); });