Files
oh-my-pi/packages/stats/src/db.ts
T
can1357 edf603d1d8 feat(stats): added model time series metrics and enhanced dashboard with performance analytics
- Added model-specific time series and performance metrics to dashboard statistics API.
- Added new ModelTimeSeriesPoint and ModelPerformancePoint types for tracking model usage and performance over time.
- Implemented getModelTimeSeries and getModelPerformanceSeries database queries to retrieve daily model metrics.
- Enhanced dashboard UI with expandable model statistics table showing inline sparklines and detailed performance charts.
- Added Model Preference stacked area chart visualizing request share distribution across models over 14 days.
- Implemented incremental build logic in server to only rebuild client when source files are modified.
2026-02-04 03:51:21 +01:00

470 lines
12 KiB
TypeScript

import { Database } from "bun:sqlite";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type {
AggregatedStats,
FolderStats,
MessageStats,
ModelPerformancePoint,
ModelStats,
ModelTimeSeriesPoint,
TimeSeriesPoint,
} from "./types";
const DB_PATH = path.join(os.homedir(), ".omp", "stats.db");
let db: Database | null = null;
/**
* Initialize the database and create tables.
*/
export async function initDb(): Promise<Database> {
if (db) return db;
// Ensure directory exists
await fs.mkdir(path.join(os.homedir(), ".omp"), { recursive: true });
db = new Database(DB_PATH);
db.exec("PRAGMA journal_mode = WAL");
// Create tables
db.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_file TEXT NOT NULL,
entry_id TEXT NOT NULL,
folder TEXT NOT NULL,
model TEXT NOT NULL,
provider TEXT NOT NULL,
api TEXT NOT NULL,
timestamp INTEGER NOT NULL,
duration INTEGER,
ttft INTEGER,
stop_reason TEXT NOT NULL,
error_message TEXT,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
cache_read_tokens INTEGER NOT NULL,
cache_write_tokens INTEGER NOT NULL,
total_tokens INTEGER NOT NULL,
cost_input REAL NOT NULL,
cost_output REAL NOT NULL,
cost_cache_read REAL NOT NULL,
cost_cache_write REAL NOT NULL,
cost_total REAL NOT NULL,
UNIQUE(session_file, entry_id)
);
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);
CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_file);
CREATE TABLE IF NOT EXISTS file_offsets (
session_file TEXT PRIMARY KEY,
offset INTEGER NOT NULL,
last_modified INTEGER NOT NULL
);
`);
return db;
}
/**
* Get the stored offset for a session file.
*/
export function getFileOffset(sessionFile: string): { offset: number; lastModified: number } | null {
if (!db) return null;
const stmt = db.prepare("SELECT offset, last_modified FROM file_offsets WHERE session_file = ?");
const row = stmt.get(sessionFile) as { offset: number; last_modified: number } | undefined;
return row ? { offset: row.offset, lastModified: row.last_modified } : null;
}
/**
* Update the stored offset for a session file.
*/
export function setFileOffset(sessionFile: string, offset: number, lastModified: number): void {
if (!db) return;
const stmt = db.prepare(`
INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified)
VALUES (?, ?, ?)
`);
stmt.run(sessionFile, offset, lastModified);
}
/**
* Insert message stats into the database.
*/
export function insertMessageStats(stats: MessageStats[]): number {
if (!db || stats.length === 0) return 0;
const stmt = db.prepare(`
INSERT OR IGNORE INTO messages (
session_file, entry_id, folder, model, provider, api, timestamp,
duration, ttft, stop_reason, error_message,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens,
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
let inserted = 0;
const insert = db.transaction(() => {
for (const s of stats) {
const result = stmt.run(
s.sessionFile,
s.entryId,
s.folder,
s.model,
s.provider,
s.api,
s.timestamp,
s.duration,
s.ttft,
s.stopReason,
s.errorMessage,
s.usage.input,
s.usage.output,
s.usage.cacheRead,
s.usage.cacheWrite,
s.usage.totalTokens,
s.usage.cost.input,
s.usage.cost.output,
s.usage.cost.cacheRead,
s.usage.cost.cacheWrite,
s.usage.cost.total,
);
if (result.changes > 0) inserted++;
}
});
insert();
return inserted;
}
/**
* Build aggregated stats from query results.
*/
function buildAggregatedStats(rows: any[]): AggregatedStats {
if (rows.length === 0) {
return {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
errorRate: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
cacheRate: 0,
totalCost: 0,
avgDuration: null,
avgTtft: null,
avgTokensPerSecond: null,
firstTimestamp: 0,
lastTimestamp: 0,
};
}
const row = rows[0];
const totalRequests = row.total_requests || 0;
const failedRequests = row.failed_requests || 0;
const successfulRequests = totalRequests - failedRequests;
const totalInputTokens = row.total_input_tokens || 0;
const totalCacheReadTokens = row.total_cache_read_tokens || 0;
return {
totalRequests,
successfulRequests,
failedRequests,
errorRate: totalRequests > 0 ? failedRequests / totalRequests : 0,
totalInputTokens,
totalOutputTokens: row.total_output_tokens || 0,
totalCacheReadTokens,
totalCacheWriteTokens: row.total_cache_write_tokens || 0,
cacheRate:
totalInputTokens + totalCacheReadTokens > 0
? totalCacheReadTokens / (totalInputTokens + totalCacheReadTokens)
: 0,
totalCost: row.total_cost || 0,
avgDuration: row.avg_duration,
avgTtft: row.avg_ttft,
avgTokensPerSecond: row.avg_tokens_per_second,
firstTimestamp: row.first_timestamp || 0,
lastTimestamp: row.last_timestamp || 0,
};
}
/**
* Get overall aggregated stats.
*/
export function getOverallStats(): AggregatedStats {
if (!db) return buildAggregatedStats([]);
const stmt = db.prepare(`
SELECT
COUNT(*) as total_requests,
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
SUM(input_tokens) as total_input_tokens,
SUM(output_tokens) as total_output_tokens,
SUM(cache_read_tokens) as total_cache_read_tokens,
SUM(cache_write_tokens) as total_cache_write_tokens,
SUM(cost_total) as total_cost,
AVG(duration) as avg_duration,
AVG(ttft) as avg_ttft,
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
MIN(timestamp) as first_timestamp,
MAX(timestamp) as last_timestamp
FROM messages
`);
const rows = stmt.all();
return buildAggregatedStats(rows);
}
/**
* Get stats grouped by model.
*/
export function getStatsByModel(): ModelStats[] {
if (!db) return [];
const stmt = db.prepare(`
SELECT
model,
provider,
COUNT(*) as total_requests,
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
SUM(input_tokens) as total_input_tokens,
SUM(output_tokens) as total_output_tokens,
SUM(cache_read_tokens) as total_cache_read_tokens,
SUM(cache_write_tokens) as total_cache_write_tokens,
SUM(cost_total) as total_cost,
AVG(duration) as avg_duration,
AVG(ttft) as avg_ttft,
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
MIN(timestamp) as first_timestamp,
MAX(timestamp) as last_timestamp
FROM messages
GROUP BY model, provider
ORDER BY total_requests DESC
`);
const rows = stmt.all() as any[];
return rows.map(row => ({
model: row.model,
provider: row.provider,
...buildAggregatedStats([row]),
}));
}
/**
* Get stats grouped by folder.
*/
export function getStatsByFolder(): FolderStats[] {
if (!db) return [];
const stmt = db.prepare(`
SELECT
folder,
COUNT(*) as total_requests,
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
SUM(input_tokens) as total_input_tokens,
SUM(output_tokens) as total_output_tokens,
SUM(cache_read_tokens) as total_cache_read_tokens,
SUM(cache_write_tokens) as total_cache_write_tokens,
SUM(cost_total) as total_cost,
AVG(duration) as avg_duration,
AVG(ttft) as avg_ttft,
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
MIN(timestamp) as first_timestamp,
MAX(timestamp) as last_timestamp
FROM messages
GROUP BY folder
ORDER BY total_requests DESC
`);
const rows = stmt.all() as any[];
return rows.map(row => ({
folder: row.folder,
...buildAggregatedStats([row]),
}));
}
/**
* Get hourly time series data.
*/
export function getTimeSeries(hours = 24): TimeSeriesPoint[] {
if (!db) return [];
const cutoff = Date.now() - hours * 60 * 60 * 1000;
const stmt = db.prepare(`
SELECT
(timestamp / 3600000) * 3600000 as bucket,
COUNT(*) as requests,
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as errors,
SUM(total_tokens) as tokens,
SUM(cost_total) as cost
FROM messages
WHERE timestamp >= ?
GROUP BY bucket
ORDER BY bucket ASC
`);
const rows = stmt.all(cutoff) as any[];
return rows.map(row => ({
timestamp: row.bucket,
requests: row.requests,
errors: row.errors,
tokens: row.tokens,
cost: row.cost,
}));
}
/**
* Get daily performance time series data for the last N days.
*/
/**
* Get daily model usage time series data for the last N days.
*/
export function getModelTimeSeries(days = 14): ModelTimeSeriesPoint[] {
if (!db) return [];
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const stmt = db.prepare(`
SELECT
(timestamp / 86400000) * 86400000 as bucket,
model,
provider,
COUNT(*) as requests
FROM messages
WHERE timestamp >= ?
GROUP BY bucket, model, provider
ORDER BY bucket ASC
`);
const rows = stmt.all(cutoff) as any[];
return rows.map(row => ({
timestamp: row.bucket,
model: row.model,
provider: row.provider,
requests: row.requests,
}));
}
/**
* Get daily model performance time series data for the last N days.
*/
export function getModelPerformanceSeries(days = 14): ModelPerformancePoint[] {
if (!db) return [];
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const stmt = db.prepare(`
SELECT
(timestamp / 86400000) * 86400000 as bucket,
model,
provider,
COUNT(*) as requests,
AVG(ttft) as avg_ttft,
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
FROM messages
WHERE timestamp >= ?
GROUP BY bucket, model, provider
ORDER BY bucket ASC
`);
const rows = stmt.all(cutoff) as any[];
return rows.map(row => ({
timestamp: row.bucket,
model: row.model,
provider: row.provider,
requests: row.requests,
avgTtft: row.avg_ttft,
avgTokensPerSecond: row.avg_tokens_per_second,
}));
}
/**
* Get total message count.
*/
export function getMessageCount(): number {
if (!db) return 0;
const stmt = db.prepare("SELECT COUNT(*) as count FROM messages");
const row = stmt.get() as { count: number };
return row.count;
}
/**
* Close the database connection.
*/
export function closeDb(): void {
if (db) {
db.close();
db = null;
}
}
function rowToMessageStats(row: any): MessageStats {
return {
id: row.id,
sessionFile: row.session_file,
entryId: row.entry_id,
folder: row.folder,
model: row.model,
provider: row.provider,
api: row.api,
timestamp: row.timestamp,
duration: row.duration,
ttft: row.ttft,
stopReason: row.stop_reason as any,
errorMessage: row.error_message,
usage: {
input: row.input_tokens,
output: row.output_tokens,
cacheRead: row.cache_read_tokens,
cacheWrite: row.cache_write_tokens,
totalTokens: row.total_tokens,
cost: {
input: row.cost_input,
output: row.cost_output,
cacheRead: row.cost_cache_read,
cacheWrite: row.cost_cache_write,
total: row.cost_total,
},
},
};
}
export function getRecentRequests(limit = 100): MessageStats[] {
if (!db) return [];
const stmt = db.prepare(`
SELECT * FROM messages
ORDER BY timestamp DESC
LIMIT ?
`);
return (stmt.all(limit) as any[]).map(rowToMessageStats);
}
export function getRecentErrors(limit = 100): MessageStats[] {
if (!db) return [];
const stmt = db.prepare(`
SELECT * FROM messages
WHERE stop_reason = 'error'
ORDER BY timestamp DESC
LIMIT ?
`);
return (stmt.all(limit) as any[]).map(rowToMessageStats);
}
export function getMessageById(id: number): MessageStats | null {
if (!db) return null;
const stmt = db.prepare("SELECT * FROM messages WHERE id = ?");
const row = stmt.get(id);
return row ? rowToMessageStats(row) : null;
}