feat(gateway): add system.sessionAnalytics usage snapshot RPC

This commit is contained in:
William Valentin
2026-02-16 14:07:42 -08:00
parent 426145386f
commit 3d7144b2c5
9 changed files with 245 additions and 3 deletions
+1
View File
@@ -1,4 +1,5 @@
export { SessionStore, parseDuration } from './store.js';
export type { SessionAnalyticsSnapshot, SessionDailyAnalyticsRow, SessionTopAnalyticsRow } from './store.js';
export { SessionManager, ManagedSession, type Session } from './manager.js';
export { SessionIndexer, tokenize } from './indexer.js';
export type { HistoryMetadata, HistoryIndexerConfig } from './indexer.js';
+11
View File
@@ -1,5 +1,6 @@
import type { Message } from '../models/types.js';
import type { SessionStore } from './store.js';
import type { SessionAnalyticsSnapshot } from './store.js';
import { auditLogger } from '../audit/index.js';
import { SessionIndexer } from './indexer.js';
import { SessionSearch, type HistorySearchResult } from './search.js';
@@ -206,4 +207,14 @@ export class SessionManager {
}
return rows.length;
}
getSessionAnalytics(opts?: { days?: number; topLimit?: number; nowMs?: number }): SessionAnalyticsSnapshot {
const nowMs = opts?.nowMs ?? Date.now();
const days = opts?.days ?? 30;
const sinceTimestamp = Math.floor((nowMs - (days * 86_400_000)) / 1000);
return this.store.getSessionAnalytics({
sinceTimestamp,
topLimit: opts?.topLimit ?? 10,
});
}
}
+23
View File
@@ -74,6 +74,29 @@ describe('SessionStore', () => {
expect(sessions).toContain('session-b');
});
it('computes session analytics snapshot', () => {
const nowMs = Date.parse('2026-02-16T12:00:00.000Z');
const nowSec = Math.floor(nowMs / 1000);
const yesterdaySec = nowSec - 86_400;
store.addMessage('session-a', { role: 'user', content: 'A1', timestamp: nowMs });
store.addMessage('session-a', { role: 'assistant', content: 'A2', timestamp: nowMs + 1000 });
store.addMessage('session-b', { role: 'user', content: 'B1', timestamp: nowMs });
store.addMessage('session-c', { role: 'user', content: 'C1', timestamp: yesterdaySec * 1000 });
const snapshot = store.getSessionAnalytics({
sinceTimestamp: yesterdaySec,
topLimit: 2,
});
expect(snapshot.totalSessions).toBe(3);
expect(snapshot.totalMessages).toBe(4);
expect(snapshot.averageMessagesPerSession).toBeCloseTo(1.33, 2);
expect(snapshot.topSessions).toHaveLength(2);
expect(snapshot.topSessions[0]?.sessionId).toBe('session-a');
expect(snapshot.daily.length).toBeGreaterThan(0);
});
it('stores and retrieves message metadata for indexed history', () => {
const indexer = new SessionIndexer({ maxKeywords: 5 });
const metadata = indexer.indexText('deploy backend release');
+82
View File
@@ -12,6 +12,26 @@ export function parseDuration(s: string): number | null {
return unit === 'h' ? Number(n) * 3600_000 : Number(n) * 86_400_000;
}
export interface SessionDailyAnalyticsRow {
day: string;
sessions: number;
messages: number;
}
export interface SessionTopAnalyticsRow {
sessionId: string;
messages: number;
lastActivity: number;
}
export interface SessionAnalyticsSnapshot {
daily: SessionDailyAnalyticsRow[];
topSessions: SessionTopAnalyticsRow[];
averageMessagesPerSession: number;
totalSessions: number;
totalMessages: number;
}
export class SessionStore {
private db: Database.Database;
@@ -267,4 +287,66 @@ export class SessionStore {
updateMessageMetadata(messageId: number, metadata: HistoryMetadata): void {
this.db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), messageId);
}
getSessionAnalytics(opts: { sinceTimestamp: number; topLimit?: number }): SessionAnalyticsSnapshot {
const since = opts.sinceTimestamp;
const topLimit = opts.topLimit ?? 10;
const dailyRows = this.db.prepare(`
SELECT
date(created_at, 'unixepoch') AS day,
COUNT(DISTINCT session_id) AS sessions,
COUNT(*) AS messages
FROM messages
WHERE created_at >= ?
GROUP BY day
ORDER BY day DESC
`).all(since) as Array<{ day: string; sessions: number; messages: number }>;
const topRows = this.db.prepare(`
SELECT
session_id,
COUNT(*) AS messages,
MAX(created_at) AS last_activity
FROM messages
WHERE created_at >= ?
GROUP BY session_id
ORDER BY messages DESC, last_activity DESC
LIMIT ?
`).all(since, topLimit) as Array<{ session_id: string; messages: number; last_activity: number }>;
const totalMessagesRow = this.db.prepare(`
SELECT COUNT(*) AS total_messages
FROM messages
WHERE created_at >= ?
`).get(since) as { total_messages: number };
const totalSessionsRow = this.db.prepare(`
SELECT COUNT(DISTINCT session_id) AS total_sessions
FROM messages
WHERE created_at >= ?
`).get(since) as { total_sessions: number };
const totalMessages = totalMessagesRow.total_messages ?? 0;
const totalSessions = totalSessionsRow.total_sessions ?? 0;
const averageMessagesPerSession = totalSessions > 0
? Math.round((totalMessages / totalSessions) * 100) / 100
: 0;
return {
daily: dailyRows.map((row) => ({
day: row.day,
sessions: row.sessions,
messages: row.messages,
})),
topSessions: topRows.map((row) => ({
sessionId: row.session_id,
messages: row.messages,
lastActivity: row.last_activity,
})),
averageMessagesPerSession,
totalSessions,
totalMessages,
};
}
}