fix: align usage analytics with Hermes state db (#350)

This commit is contained in:
Zhicheng Han
2026-04-30 13:46:31 +02:00
committed by GitHub
parent 05f15da90b
commit dac9006b3e
10 changed files with 591 additions and 113 deletions
@@ -1,4 +1,5 @@
import { getActiveProfileDir, getProfileDir } from '../../services/hermes/hermes-profile'
import type { LocalUsageStats } from './usage-store'
const SQLITE_AVAILABLE = (() => {
const [major, minor] = process.versions.node.split('.').map(Number)
@@ -696,6 +697,125 @@ export async function getSessionDetailFromDbWithProfile(sessionId: string, profi
}
}
export interface HermesUsageStats extends LocalUsageStats {
cost: number
total_api_calls: number
}
function tableHasColumn(
db: { prepare: (sql: string) => { all: (...params: any[]) => Record<string, unknown>[] } },
tableName: string,
columnName: string,
): boolean {
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all()
return columns.some(column => String(column.name || '') === columnName)
}
export async function getUsageStatsFromDb(
days = 30,
nowSeconds = Math.floor(Date.now() / 1000),
): Promise<HermesUsageStats> {
const empty: HermesUsageStats = {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
sessions: 0,
by_model: [],
by_day: [],
cost: 0,
total_api_calls: 0,
}
const normalizedDays = Number.isFinite(days) ? days : 30
const safeDays = Math.max(1, Math.floor(normalizedDays))
const since = nowSeconds - safeDays * 24 * 60 * 60
const db = await openSessionDb()
try {
const apiCallsExpr = tableHasColumn(db, 'sessions', 'api_call_count')
? 'COALESCE(SUM(api_call_count), 0)'
: '0'
const sourceFilter = tableHasColumn(db, 'sessions', 'source')
? " AND COALESCE(source, '') != 'api_server'"
: ''
const totals = db.prepare(`
SELECT
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens,
COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens,
COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) AS cost,
COUNT(*) AS sessions,
${apiCallsExpr} AS total_api_calls
FROM sessions
WHERE started_at > ?${sourceFilter}
`).get(since) as Record<string, unknown> | undefined
if (!totals) return empty
const byModel = db.prepare(`
SELECT
COALESCE(model, '') AS model,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens,
COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens,
COUNT(*) AS sessions
FROM sessions
WHERE started_at > ?${sourceFilter} AND model IS NOT NULL
GROUP BY model
ORDER BY COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0) DESC
`).all(since).map(row => ({
model: String(row.model || ''),
input_tokens: normalizeNumber(row.input_tokens),
output_tokens: normalizeNumber(row.output_tokens),
cache_read_tokens: normalizeNumber(row.cache_read_tokens),
cache_write_tokens: normalizeNumber(row.cache_write_tokens),
reasoning_tokens: normalizeNumber(row.reasoning_tokens),
sessions: normalizeNumber(row.sessions),
}))
const byDay = db.prepare(`
SELECT
date(started_at, 'unixepoch') AS date,
COALESCE(SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)), 0) AS tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache,
COUNT(*) AS sessions,
COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) AS cost
FROM sessions
WHERE started_at > ?${sourceFilter}
GROUP BY date
ORDER BY date ASC
`).all(since).map(row => ({
date: String(row.date || ''),
tokens: normalizeNumber(row.tokens),
cache: normalizeNumber(row.cache),
sessions: normalizeNumber(row.sessions),
cost: normalizeNumber(row.cost),
}))
return {
input_tokens: normalizeNumber(totals.input_tokens),
output_tokens: normalizeNumber(totals.output_tokens),
cache_read_tokens: normalizeNumber(totals.cache_read_tokens),
cache_write_tokens: normalizeNumber(totals.cache_write_tokens),
reasoning_tokens: normalizeNumber(totals.reasoning_tokens),
sessions: normalizeNumber(totals.sessions),
by_model: byModel,
by_day: byDay,
cost: normalizeNumber(totals.cost),
total_api_calls: normalizeNumber(totals.total_api_calls),
}
} finally {
db.close()
}
}
export async function listSessionSummaries(source?: string, limit = 2000, profile?: string): Promise<HermesSessionRow[]> {
if (!SQLITE_AVAILABLE) {
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
+29 -30
View File
@@ -154,7 +154,7 @@ export interface LocalUsageStats {
by_day: UsageStatsDailyRow[]
}
export function getLocalUsageStats(profile?: string): LocalUsageStats {
export function getLocalUsageStats(profile?: string, days = 30): LocalUsageStats {
const empty: LocalUsageStats = {
input_tokens: 0, output_tokens: 0, cache_read_tokens: 0,
cache_write_tokens: 0, reasoning_tokens: 0, sessions: 0,
@@ -163,7 +163,15 @@ export function getLocalUsageStats(profile?: string): LocalUsageStats {
if (!isSqliteAvailable()) return empty
const db = getDb()!
const profileFilter = profile ? `WHERE profile = ?` : ''
const safeDays = Math.max(1, Math.floor(Number.isFinite(days) ? days : 30))
const cutoffMs = Date.now() - safeDays * 24 * 60 * 60 * 1000
const filters: string[] = ['created_at > ?']
const params: any[] = [cutoffMs]
if (profile) {
filters.unshift('profile = ?')
params.unshift(profile)
}
const whereClause = `WHERE ${filters.join(' AND ')}`
const totals = db.prepare(`
SELECT COALESCE(SUM(input_tokens),0) as input_tokens,
@@ -173,42 +181,33 @@ export function getLocalUsageStats(profile?: string): LocalUsageStats {
COALESCE(SUM(reasoning_tokens),0) as reasoning_tokens,
COUNT(DISTINCT session_id) as sessions
FROM ${TABLE}
${profileFilter}
`).get(...(profile ? [profile] : [])) as any
${whereClause}
`).get(...params) as any
const byModel = db.prepare(`
SELECT model,
SUM(input_tokens) as input_tokens,
SUM(output_tokens) as output_tokens,
SUM(cache_read_tokens) as cache_read_tokens,
SUM(cache_write_tokens) as cache_write_tokens,
SUM(reasoning_tokens) as reasoning_tokens,
COALESCE(SUM(input_tokens),0) as input_tokens,
COALESCE(SUM(output_tokens),0) as output_tokens,
COALESCE(SUM(cache_read_tokens),0) as cache_read_tokens,
COALESCE(SUM(cache_write_tokens),0) as cache_write_tokens,
COALESCE(SUM(reasoning_tokens),0) as reasoning_tokens,
COUNT(DISTINCT session_id) as sessions
FROM ${TABLE}
${profileFilter}
${whereClause}
GROUP BY model
ORDER BY sessions DESC
`).all(...(profile ? [profile] : [])) as unknown as UsageStatsModelRow[]
ORDER BY COALESCE(SUM(input_tokens),0) + COALESCE(SUM(output_tokens),0) DESC
`).all(...params) as unknown as UsageStatsModelRow[]
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000
const byDayStmt = profile
? `SELECT DATE(created_at / 1000, 'unixepoch') as date,
SUM(input_tokens + output_tokens) as tokens,
SUM(cache_read_tokens) as cache,
const byDay = db.prepare(`
SELECT DATE(created_at / 1000, 'unixepoch') as date,
COALESCE(SUM(input_tokens + output_tokens),0) as tokens,
COALESCE(SUM(cache_read_tokens),0) as cache,
COUNT(DISTINCT session_id) as sessions
FROM ${TABLE}
WHERE profile = ? AND created_at > ?
GROUP BY date
ORDER BY date`
: `SELECT DATE(created_at / 1000, 'unixepoch') as date,
SUM(input_tokens + output_tokens) as tokens,
SUM(cache_read_tokens) as cache,
COUNT(DISTINCT session_id) as sessions
FROM ${TABLE}
WHERE created_at > ?
GROUP BY date
ORDER BY date`
const byDay = db.prepare(byDayStmt).all(...(profile ? [profile, thirtyDaysAgo] : [thirtyDaysAgo])) as Array<{ date: string; tokens: number; cache: number; sessions: number }>
FROM ${TABLE}
${whereClause}
GROUP BY date
ORDER BY date
`).all(...params) as Array<{ date: string; tokens: number; cache: number; sessions: number }>
return {
input_tokens: totals.input_tokens,