refactor: replace better-sqlite3 with lowdb for request details storage

- Removed better-sqlite3 dependency and implemented lowdb for JSON-based storage.
- Updated observability settings handling and configuration retrieval.
- Cleaned up unused observability settings UI components in ProfilePage.
- Adjusted contributors badge display in README for better visibility.
This commit is contained in:
decolua
2026-03-09 16:50:18 +07:00
parent 753a04b49e
commit 8c8eeecc70
5 changed files with 206 additions and 592 deletions
+1 -1
View File
@@ -1270,7 +1270,7 @@ Full architecture reference: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
Thanks to all contributors who helped make 9Router better! Thanks to all contributors who helped make 9Router better!
[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=200&columns=10&anon=1)](https://github.com/decolua/9router/graphs/contributors) [![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1)](https://github.com/decolua/9router/graphs/contributors)
--- ---
-1
View File
@@ -15,7 +15,6 @@
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.2",
"express": "^5.2.1", "express": "^5.2.1",
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"http-proxy-middleware": "^3.0.5", "http-proxy-middleware": "^3.0.5",
+1 -103
View File
@@ -223,24 +223,6 @@ export default function ProfilePage() {
} }
}; };
const updateObservabilitySetting = async (key, value) => {
const numValue = parseInt(value);
if (isNaN(numValue) || numValue < 1) return;
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: numValue }),
});
if (res.ok) {
setSettings(prev => ({ ...prev, [key]: numValue }));
}
} catch (err) {
console.error(`Failed to update ${key}:`, err);
}
};
const updateObservabilityEnabled = async (enabled) => { const updateObservabilityEnabled = async (enabled) => {
try { try {
const res = await fetch("/api/settings", { const res = await fetch("/api/settings", {
@@ -623,12 +605,11 @@ export default function ProfilePage() {
</div> </div>
<h3 className="text-lg font-semibold">Observability</h3> <h3 className="text-lg font-semibold">Observability</h3>
</div> </div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="font-medium">Enable Observability</p> <p className="font-medium">Enable Observability</p>
<p className="text-sm text-text-muted"> <p className="text-sm text-text-muted">
Turn request detail recording on/off globally Record request details for inspection in the logs view
</p> </p>
</div> </div>
<Toggle <Toggle
@@ -637,89 +618,6 @@ export default function ProfilePage() {
disabled={loading} disabled={loading}
/> />
</div> </div>
<div className={cn("flex flex-col gap-4", !observabilityEnabled && "opacity-60")}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Max Records</p>
<p className="text-sm text-text-muted">
Maximum request detail records to keep (older records are auto-deleted)
</p>
</div>
<Input
type="number"
min="100"
max="10000"
step="100"
value={settings.observabilityMaxRecords || 1000}
onChange={(e) => updateObservabilitySetting("observabilityMaxRecords", parseInt(e.target.value))}
disabled={loading || !observabilityEnabled}
className="w-28 text-center"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Batch Size</p>
<p className="text-sm text-text-muted">
Number of items to accumulate before writing to database (higher = better performance)
</p>
</div>
<Input
type="number"
min="5"
max="100"
step="5"
value={settings.observabilityBatchSize || 20}
onChange={(e) => updateObservabilitySetting("observabilityBatchSize", parseInt(e.target.value))}
disabled={loading || !observabilityEnabled}
className="w-28 text-center"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Flush Interval (ms)</p>
<p className="text-sm text-text-muted">
Maximum time to wait before flushing buffer (prevents data loss during low traffic)
</p>
</div>
<Input
type="number"
min="1000"
max="30000"
step="1000"
value={settings.observabilityFlushIntervalMs || 5000}
onChange={(e) => updateObservabilitySetting("observabilityFlushIntervalMs", parseInt(e.target.value))}
disabled={loading || !observabilityEnabled}
className="w-28 text-center"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Max JSON Size (KB)</p>
<p className="text-sm text-text-muted">
Maximum size for each JSON field (request/response) before truncation
</p>
</div>
<Input
type="number"
min="100"
max="10240"
step="100"
value={settings.observabilityMaxJsonSize || 1024}
onChange={(e) => updateObservabilitySetting("observabilityMaxJsonSize", parseInt(e.target.value))}
disabled={loading || !observabilityEnabled}
className="w-28 text-center"
/>
</div>
<p className="text-xs text-text-muted italic pt-2 border-t border-border/50">
Current: Keeps {settings.observabilityMaxRecords || 1000} records, batches every {settings.observabilityBatchSize || 20} requests, max {settings.observabilityMaxJsonSize || 1024}KB per field
</p>
</div>
</div>
</Card> </Card>
{/* App Info */} {/* App Info */}
+8 -28
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getRequestDetailsDb } from "@/lib/requestDetailsDb"; import { getRequestDetails } from "@/lib/requestDetailsDb";
import { getProviderNodes } from "@/lib/localDb"; import { getProviderNodes } from "@/lib/localDb";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers"; import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
@@ -9,46 +9,26 @@ import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
*/ */
export async function GET() { export async function GET() {
try { try {
const db = await getRequestDetailsDb(); const { details } = await getRequestDetails({ pageSize: 9999 });
const stmt = db.prepare(` // Extract unique providers
SELECT DISTINCT provider const providerIds = [...new Set(details.map(r => r.provider).filter(Boolean))].sort();
FROM request_details
WHERE provider IS NOT NULL AND provider != ''
ORDER BY provider ASC
`);
const rows = stmt.all();
// Fetch all provider nodes to get names for custom providers
const providerNodes = await getProviderNodes(); const providerNodes = await getProviderNodes();
const nodeMap = {}; const nodeMap = {};
for (const node of providerNodes) { for (const node of providerNodes) {
nodeMap[node.id] = node.name; nodeMap[node.id] = node.name;
} }
const providers = rows.map(row => { const providers = providerIds.map(providerId => {
const providerId = row.provider;
// Try to find name from various sources
let name = providerId; let name = providerId;
// 1. Check if it's a custom provider node
if (nodeMap[providerId]) { if (nodeMap[providerId]) {
name = nodeMap[providerId]; name = nodeMap[providerId];
} } else {
// 2. Check predefined providers
else {
const providerConfig = getProviderByAlias(providerId) || AI_PROVIDERS[providerId]; const providerConfig = getProviderByAlias(providerId) || AI_PROVIDERS[providerId];
if (providerConfig?.name) { if (providerConfig?.name) name = providerConfig.name;
name = providerConfig.name;
} }
} return { id: providerId, name };
return {
id: providerId,
name
};
}); });
return NextResponse.json({ providers }); return NextResponse.json({ providers });
+180 -443
View File
@@ -1,19 +1,65 @@
import Database from "better-sqlite3"; import { Low } from "lowdb";
import path from "path"; import { JSONFile } from "lowdb/node";
import os from "os"; import path from "node:path";
import fs from "fs"; import os from "node:os";
import fs from "node:fs";
const isCloud = typeof caches !== 'undefined' || typeof caches === 'object'; const isCloud = typeof caches !== "undefined" && typeof caches === "object";
// ============================================================================ const DEFAULT_MAX_RECORDS = 200;
// CONFIGURATION: Batch Processing Settings const DEFAULT_BATCH_SIZE = 20;
// ============================================================================ const DEFAULT_FLUSH_INTERVAL_MS = 5000;
const DEFAULT_MAX_JSON_SIZE = 5 * 1024; // 5KB default, configurable via settings
const CONFIG_CACHE_TTL_MS = 5000;
function getAppName() {
return "9router";
}
function getUserDataDir() {
if (isCloud) return "/tmp";
if (process.env.DATA_DIR) return process.env.DATA_DIR;
const platform = process.platform;
const homeDir = os.homedir();
const appName = getAppName();
if (platform === "win32") {
return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName);
}
return path.join(homeDir, `.${appName}`);
}
const DATA_DIR = getUserDataDir();
const DB_FILE = isCloud ? null : path.join(DATA_DIR, "request-details.json");
if (!isCloud && !fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
let dbInstance = null;
async function getDb() {
if (isCloud) return null;
if (!dbInstance) {
const adapter = new JSONFile(DB_FILE);
const db = new Low(adapter, { records: [] });
await db.read();
if (!db.data?.records) db.data = { records: [] };
dbInstance = db;
}
return dbInstance;
}
// Config cache
let cachedConfig = null;
let cachedConfigTs = 0;
/**
* Get observability configuration from settings.
* Falls back to environment variables, then defaults.
*/
async function getObservabilityConfig() { async function getObservabilityConfig() {
if (cachedConfig && (Date.now() - cachedConfigTs) < CONFIG_CACHE_TTL_MS) {
return cachedConfig;
}
try { try {
const { getSettings } = await import("@/lib/localDb"); const { getSettings } = await import("@/lib/localDb");
const settings = await getSettings(); const settings = await getSettings();
@@ -22,266 +68,120 @@ async function getObservabilityConfig() {
? settings.observabilityEnabled ? settings.observabilityEnabled
: envEnabled; : envEnabled;
return { cachedConfig = {
enabled, enabled,
maxRecords: settings.observabilityMaxRecords || parseInt(process.env.OBSERVABILITY_MAX_RECORDS || '1000', 10), maxRecords: settings.observabilityMaxRecords || parseInt(process.env.OBSERVABILITY_MAX_RECORDS || String(DEFAULT_MAX_RECORDS), 10),
batchSize: settings.observabilityBatchSize || parseInt(process.env.OBSERVABILITY_BATCH_SIZE || '20', 10), batchSize: settings.observabilityBatchSize || parseInt(process.env.OBSERVABILITY_BATCH_SIZE || String(DEFAULT_BATCH_SIZE), 10),
flushIntervalMs: settings.observabilityFlushIntervalMs || parseInt(process.env.OBSERVABILITY_FLUSH_INTERVAL_MS || '5000', 10), flushIntervalMs: settings.observabilityFlushIntervalMs || parseInt(process.env.OBSERVABILITY_FLUSH_INTERVAL_MS || String(DEFAULT_FLUSH_INTERVAL_MS), 10),
maxJsonSize: (settings.observabilityMaxJsonSize || parseInt(process.env.OBSERVABILITY_MAX_JSON_SIZE || '1024', 10)) * 1024 maxJsonSize: (settings.observabilityMaxJsonSize || parseInt(process.env.OBSERVABILITY_MAX_JSON_SIZE || "5", 10)) * 1024,
}; };
} catch (error) { } catch {
console.error("[requestDetailsDb] Failed to load observability config:", error); cachedConfig = {
return {
enabled: true, enabled: true,
maxRecords: 1000, maxRecords: DEFAULT_MAX_RECORDS,
batchSize: 20, batchSize: DEFAULT_BATCH_SIZE,
flushIntervalMs: 5000, flushIntervalMs: DEFAULT_FLUSH_INTERVAL_MS,
maxJsonSize: 1024 * 1024 maxJsonSize: DEFAULT_MAX_JSON_SIZE,
}; };
} }
}
// Cache config to avoid repeated database reads
let cachedConfig = null;
let cachedConfigTs = 0;
const CONFIG_CACHE_TTL_MS = 5000;
async function getCachedObservabilityConfig() {
if (!cachedConfig || (Date.now() - cachedConfigTs) > CONFIG_CACHE_TTL_MS) {
cachedConfig = await getObservabilityConfig();
cachedConfigTs = Date.now(); cachedConfigTs = Date.now();
}
return cachedConfig; return cachedConfig;
} }
let dbInstance = null; // Batch write queue
// Get app name
function getAppName() {
return "9router";
}
// Get user data directory based on platform
function getUserDataDir() {
if (isCloud) return "/tmp";
if (process.env.DATA_DIR) return process.env.DATA_DIR;
try {
const platform = process.platform;
const homeDir = os.homedir();
const appName = getAppName();
if (platform === "win32") {
return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName);
} else {
return path.join(homeDir, `.${appName}`);
}
} catch (error) {
console.error("[requestDetailsDb] Failed to get user data directory:", error.message);
return path.join(process.cwd(), ".9router");
}
}
// Database file path
const DATA_DIR = getUserDataDir();
const DB_FILE = isCloud ? null : path.join(DATA_DIR, "request-details.sqlite");
// Ensure data directory exists
if (!isCloud && fs && typeof fs.existsSync === "function") {
try {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
} catch (error) {
console.error("[requestDetailsDb] Failed to create data directory:", error.message);
}
}
// ============================================================================
// BATCH WRITE QUEUE
// ============================================================================
/**
* In-memory buffer for batch writes.
* Accumulates request details before flushing to database in a transaction.
* @type {Array<object>}
*/
let writeBuffer = []; let writeBuffer = [];
/**
* Timer reference for auto-flush mechanism.
* Ensures data is written even during low traffic periods.
* @type {NodeJS.Timeout|null}
*/
let flushTimer = null; let flushTimer = null;
/**
* Flag indicating if a flush operation is currently in progress.
* Prevents concurrent flushes.
* @type {boolean}
*/
let isFlushing = false; let isFlushing = false;
/** function safeJsonStringify(obj, maxSize) {
* Get SQLite database instance (singleton) try {
*/ const str = JSON.stringify(obj);
export async function getRequestDetailsDb() { if (str.length > maxSize) {
if (isCloud) { return JSON.stringify({ _truncated: true, _originalSize: str.length, _preview: str.substring(0, 200) });
// In-memory mock for Workers
if (!dbInstance) {
dbInstance = {
prepare: () => ({
run: () => {},
get: () => null,
all: () => []
}),
exec: () => {},
pragma: () => {}
};
} }
return dbInstance; return str;
} catch {
return "{}";
} }
if (!dbInstance) {
const db = new Database(DB_FILE);
// Configure for better concurrency
db.pragma('journal_mode = WAL'); // Write-Ahead Logging for concurrent access
db.pragma('synchronous = NORMAL'); // Faster than FULL, still safe
db.pragma('cache_size = -64000'); // 64MB cache
db.pragma('temp_store = MEMORY'); // Use memory for temp tables
// Create table with indexes
db.exec(`
CREATE TABLE IF NOT EXISTS request_details (
id TEXT PRIMARY KEY,
provider TEXT,
model TEXT,
connection_id TEXT,
timestamp INTEGER NOT NULL,
status TEXT,
latency TEXT,
tokens TEXT,
request TEXT,
provider_request TEXT,
provider_response TEXT,
response TEXT
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_timestamp
ON request_details(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_provider
ON request_details(provider);
CREATE INDEX IF NOT EXISTS idx_model
ON request_details(model);
CREATE INDEX IF NOT EXISTS idx_connection
ON request_details(connection_id);
CREATE INDEX IF NOT EXISTS idx_status
ON request_details(status);
`);
dbInstance = db;
// Register shutdown handler on first database initialization
ensureShutdownHandler();
}
return dbInstance;
} }
/** function sanitizeHeaders(headers) {
* Generate unique ID for request detail if (!headers || typeof headers !== "object") return {};
*/ const sensitiveKeys = ["authorization", "x-api-key", "cookie", "token", "api-key"];
const sanitized = { ...headers };
for (const key of Object.keys(sanitized)) {
if (sensitiveKeys.some(s => key.toLowerCase().includes(s))) {
delete sanitized[key];
}
}
return sanitized;
}
function generateDetailId(model) { function generateDetailId(model) {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const random = Math.random().toString(36).substring(2, 8); const random = Math.random().toString(36).substring(2, 8);
const modelPart = model ? model.replace(/[^a-zA-Z0-9-]/g, '-') : 'unknown'; const modelPart = model ? model.replace(/[^a-zA-Z0-9-]/g, "-") : "unknown";
return `${timestamp}-${random}-${modelPart}`; return `${timestamp}-${random}-${modelPart}`;
} }
/**
* Flush all buffered items to database in a single transaction.
* This function is called automatically when:
* 1. Buffer size reaches OBSERVABILITY_BATCH_SIZE
* 2. OBSERVABILITY_FLUSH_INTERVAL_MS elapses
* 3. Process is shutting down (graceful shutdown)
*
* @private
*/
async function flushToDatabase() { async function flushToDatabase() {
if (isCloud || isFlushing || writeBuffer.length === 0) { if (isCloud || isFlushing || writeBuffer.length === 0) return;
return;
}
isFlushing = true; isFlushing = true;
try { try {
// Take a snapshot of the buffer and clear it immediately
const itemsToSave = [...writeBuffer]; const itemsToSave = [...writeBuffer];
writeBuffer = []; writeBuffer = [];
const db = await getRequestDetailsDb(); const db = await getDb();
const config = await getObservabilityConfig(); const config = await getObservabilityConfig();
// Prepare statements outside transaction for better performance for (const item of itemsToSave) {
const insertStmt = db.prepare(` if (!item.id) item.id = generateDetailId(item.model);
INSERT OR REPLACE INTO request_details if (!item.timestamp) item.timestamp = new Date().toISOString();
(id, provider, model, connection_id, timestamp, status, latency, tokens, if (item.request?.headers) item.request.headers = sanitizeHeaders(item.request.headers);
request, provider_request, provider_response, response)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const deleteStmt = db.prepare(` // Serialize large fields
DELETE FROM request_details const record = {
WHERE id NOT IN ( id: item.id,
SELECT id FROM request_details provider: item.provider || null,
ORDER BY timestamp DESC model: item.model || null,
LIMIT ? connectionId: item.connectionId || null,
) timestamp: item.timestamp,
`); status: item.status || null,
latency: item.latency || {},
tokens: item.tokens || {},
request: item.request || {},
providerRequest: item.providerRequest || {},
providerResponse: item.providerResponse || {},
response: item.response || {},
};
// Execute all writes in a single transaction for atomicity // Truncate oversized JSON fields
const transaction = db.transaction((items) => { const maxSize = config.maxJsonSize;
const maxJsonSize = config.maxJsonSize; for (const field of ["request", "providerRequest", "providerResponse", "response"]) {
const str = JSON.stringify(record[field]);
for (const item of items) { if (str.length > maxSize) {
if (!item.id) { record[field] = { _truncated: true, _originalSize: str.length, _preview: str.substring(0, 200) };
item.id = generateDetailId(item.model); }
} }
if (!item.timestamp) { // Upsert: replace existing record with same id
item.timestamp = new Date().toISOString(); const idx = db.data.records.findIndex(r => r.id === record.id);
if (idx !== -1) {
db.data.records[idx] = record;
} else {
db.data.records.push(record);
}
} }
// Sanitize headers if present // Keep only latest maxRecords (sorted by timestamp desc)
if (item.request && item.request.headers) { db.data.records.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
item.request.headers = sanitizeHeaders(item.request.headers); if (db.data.records.length > config.maxRecords) {
db.data.records = db.data.records.slice(0, config.maxRecords);
} }
insertStmt.run( await db.write();
item.id,
item.provider || null,
item.model || null,
item.connectionId || null,
new Date(item.timestamp).getTime(),
item.status || null,
JSON.stringify(item.latency || {}),
JSON.stringify(item.tokens || {}),
safeJsonStringify(item.request || {}, maxJsonSize),
safeJsonStringify(item.providerRequest || {}, maxJsonSize),
safeJsonStringify(item.providerResponse || {}, maxJsonSize),
safeJsonStringify(item.response || {}, maxJsonSize)
);
}
// Cleanup old records once per batch (not per item)
deleteStmt.run(config.maxRecords);
});
transaction(itemsToSave);
} catch (error) { } catch (error) {
console.error("[requestDetailsDb] Batch write failed:", error); console.error("[requestDetailsDb] Batch write failed:", error);
} finally { } finally {
@@ -289,68 +189,17 @@ async function flushToDatabase() {
} }
} }
/**
* Safely stringify an object with a size limit.
* Truncates the result if it exceeds the limit.
* @param {object} obj - Object to stringify
* @param {number} maxSize - Maximum string size in bytes
* @returns {string}
*/
function safeJsonStringify(obj, maxSize) {
try {
const str = JSON.stringify(obj);
if (str.length > maxSize) {
// Return valid JSON instead of truncated invalid string
return JSON.stringify({ _truncated: true, _originalSize: str.length, _preview: str.substring(0, 200) });
}
return str;
} catch (error) {
return JSON.stringify({ error: "Failed to stringify object", message: error.message });
}
}
/**
* Sanitize sensitive headers from request
*/
function sanitizeHeaders(headers) {
if (!headers || typeof headers !== 'object') return {};
const sensitiveKeys = ['authorization', 'x-api-key', 'cookie', 'token', 'api-key'];
const sanitized = { ...headers };
for (const key of Object.keys(sanitized)) {
if (sensitiveKeys.some(sensitive => key.toLowerCase().includes(sensitive))) {
delete sanitized[key];
}
}
return sanitized;
}
/**
* Save request detail to SQLite (batched for performance).
* Details are accumulated in memory and flushed to database in batches.
*
* @param {object} detail - Request detail object
* @see {@link flushToDatabase} for batch write implementation
*/
export async function saveRequestDetail(detail) { export async function saveRequestDetail(detail) {
if (isCloud) return; if (isCloud) return;
const config = await getCachedObservabilityConfig(); const config = await getObservabilityConfig();
if (!config.enabled) { if (!config.enabled) return;
return;
}
writeBuffer.push(detail); writeBuffer.push(detail);
if (writeBuffer.length >= config.batchSize) { if (writeBuffer.length >= config.batchSize) {
await flushToDatabase(); await flushToDatabase();
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
} else if (!flushTimer) { } else if (!flushTimer) {
flushTimer = setTimeout(() => { flushTimer = setTimeout(() => {
flushToDatabase().catch(() => {}); flushToDatabase().catch(() => {});
@@ -359,173 +208,61 @@ export async function saveRequestDetail(detail) {
} }
} }
// ============================================================================ export async function getRequestDetails(filter = {}) {
// GRACEFUL SHUTDOWN HANDLER if (isCloud) {
// ============================================================================ return { details: [], pagination: { page: 1, pageSize: 50, totalItems: 0, totalPages: 0, hasNext: false, hasPrev: false } };
}
const db = await getDb();
let records = [...db.data.records];
// Apply filters
if (filter.provider) records = records.filter(r => r.provider === filter.provider);
if (filter.model) records = records.filter(r => r.model === filter.model);
if (filter.connectionId) records = records.filter(r => r.connectionId === filter.connectionId);
if (filter.status) records = records.filter(r => r.status === filter.status);
if (filter.startDate) records = records.filter(r => new Date(r.timestamp) >= new Date(filter.startDate));
if (filter.endDate) records = records.filter(r => new Date(r.timestamp) <= new Date(filter.endDate));
// Sort desc
records.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
const totalItems = records.length;
const page = filter.page || 1;
const pageSize = filter.pageSize || 50;
const totalPages = Math.ceil(totalItems / pageSize);
const details = records.slice((page - 1) * pageSize, page * pageSize);
return {
details,
pagination: { page, pageSize, totalItems, totalPages, hasNext: page < totalPages, hasPrev: page > 1 },
};
}
export async function getRequestDetailById(id) {
if (isCloud) return null;
const db = await getDb();
return db.data.records.find(r => r.id === id) || null;
}
// Graceful shutdown
let shutdownHandlerRegistered = false; let shutdownHandlerRegistered = false;
/**
* Register process shutdown handlers to flush remaining data before exit.
* Should be called once when the module initializes.
*/
function ensureShutdownHandler() { function ensureShutdownHandler() {
if (shutdownHandlerRegistered || isCloud) { if (shutdownHandlerRegistered || isCloud) return;
return;
}
const handler = async () => { const handler = async () => {
// Clear timer to prevent any pending flush if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
if (flushTimer) { if (writeBuffer.length > 0) await flushToDatabase();
clearTimeout(flushTimer);
flushTimer = null;
}
// Flush any remaining data in buffer
if (writeBuffer.length > 0) {
console.log(`[requestDetailsDb] Flushing ${writeBuffer.length} items before shutdown...`);
await flushToDatabase();
}
}; };
// Register handlers for various termination signals process.on("beforeExit", handler);
process.on('beforeExit', handler); process.on("SIGINT", handler);
process.on('SIGINT', handler); process.on("SIGTERM", handler);
process.on('SIGTERM', handler); process.on("exit", handler);
process.on('exit', handler);
shutdownHandlerRegistered = true; shutdownHandlerRegistered = true;
} }
/** ensureShutdownHandler();
* Get request details with filtering and pagination
* @param {object} filter - Filter options
* @returns {Promise<object>} Details with pagination info
*/
export async function getRequestDetails(filter = {}) {
const db = await getRequestDetailsDb();
if (isCloud) {
return { details: [], pagination: { page: 1, pageSize: filter.pageSize || 50, totalItems: 0, totalPages: 0, hasNext: false, hasPrev: false } };
}
let query = 'SELECT * FROM request_details WHERE 1=1';
const params = [];
if (filter.provider) {
query += ' AND provider = ?';
params.push(filter.provider);
}
if (filter.model) {
query += ' AND model = ?';
params.push(filter.model);
}
if (filter.connectionId) {
query += ' AND connection_id = ?';
params.push(filter.connectionId);
}
if (filter.status) {
query += ' AND status = ?';
params.push(filter.status);
}
if (filter.startDate) {
query += ' AND timestamp >= ?';
params.push(new Date(filter.startDate).getTime());
}
if (filter.endDate) {
query += ' AND timestamp <= ?';
params.push(new Date(filter.endDate).getTime());
}
// Get total count first
const countQuery = query.replace('SELECT *', 'SELECT COUNT(*)');
const countStmt = db.prepare(countQuery);
const totalResult = countStmt.get(...params);
const total = totalResult['COUNT(*)'];
// Add pagination
query += ' ORDER BY timestamp DESC';
const page = filter.page || 1;
const pageSize = filter.pageSize || 50;
query += ' LIMIT ? OFFSET ?';
params.push(pageSize, (page - 1) * pageSize);
// Execute query
const stmt = db.prepare(query);
const rows = stmt.all(...params);
// Safe JSON parse — returns fallback on corrupt/truncated data
const safeJsonParse = (str, fallback = {}) => {
try { return JSON.parse(str || '{}'); }
catch { return fallback; }
};
// Convert back to original format
const details = rows.map(row => ({
id: row.id,
provider: row.provider,
model: row.model,
connectionId: row.connection_id,
timestamp: new Date(row.timestamp).toISOString(),
status: row.status,
latency: safeJsonParse(row.latency),
tokens: safeJsonParse(row.tokens),
request: safeJsonParse(row.request),
providerRequest: safeJsonParse(row.provider_request),
providerResponse: safeJsonParse(row.provider_response),
response: safeJsonParse(row.response)
}));
return {
details,
pagination: {
page,
pageSize,
totalItems: total,
totalPages: Math.ceil(total / pageSize),
hasNext: page < Math.ceil(total / pageSize),
hasPrev: page > 1
}
};
}
/**
* Get single request detail by ID
* @param {string} id - Request detail ID
* @returns {Promise<object|null>} Request detail or null
*/
export async function getRequestDetailById(id) {
const db = await getRequestDetailsDb();
if (isCloud) return null;
const stmt = db.prepare('SELECT * FROM request_details WHERE id = ?');
const row = stmt.get(id);
if (!row) return null;
const safeJsonParse = (str, fallback = {}) => {
try { return JSON.parse(str || '{}'); }
catch { return fallback; }
};
return {
id: row.id,
provider: row.provider,
model: row.model,
connectionId: row.connection_id,
timestamp: new Date(row.timestamp).toISOString(),
status: row.status,
latency: safeJsonParse(row.latency),
tokens: safeJsonParse(row.tokens),
request: safeJsonParse(row.request),
providerRequest: safeJsonParse(row.provider_request),
providerResponse: safeJsonParse(row.provider_response),
response: safeJsonParse(row.response)
};
}