feat(gateway): add system.sessionAnalytics usage snapshot RPC
This commit is contained in:
@@ -303,6 +303,42 @@ Online/offline is inferred from inactivity threshold in the daemon.
|
||||
}
|
||||
```
|
||||
|
||||
#### `system.sessionAnalytics`
|
||||
|
||||
Return aggregate session analytics from the SQLite message history.
|
||||
|
||||
Useful for operator dashboards and trend checks (sessions/day, message volume, top active sessions).
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"id": 10,
|
||||
"method": "system.sessionAnalytics",
|
||||
"params": {
|
||||
"days": 30,
|
||||
"topLimit": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 10,
|
||||
"result": {
|
||||
"daily": [
|
||||
{ "day": "2026-02-16", "sessions": 14, "messages": 228 }
|
||||
],
|
||||
"topSessions": [
|
||||
{ "sessionId": "telegram:123456", "messages": 42, "lastActivity": 1739700300 }
|
||||
],
|
||||
"averageMessagesPerSession": 16.29,
|
||||
"totalSessions": 14,
|
||||
"totalMessages": 228
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
|
||||
+11
-3
@@ -7,7 +7,7 @@
|
||||
"status": "completed",
|
||||
"date": "2026-02-16",
|
||||
"updated": "2026-02-16",
|
||||
"summary": "Added first-class automation presets and scheduling upgrades: `automation.daily_briefing` now auto-registers an opinionated cron job for morning briefings, and backup scheduling now supports cron expressions via `backup.schedule` plus optional `backup.run_on_start` while preserving interval fallback. Added `BackupScheduler` with `backup.notify` channel alerts, configurable `backup.failure_threshold`, and recovery notifications (`backup.notify_recovery`) so backup failures/recoveries proactively notify operators. Extended heartbeat monitoring with `process_memory`, `backup`, and `provider_errors` checks (with thresholds) so high RSS usage, backup failure streaks, and model-provider error spikes proactively trigger health alerts. Added timezone-safe daily briefing dedupe via `automation.daily_briefing.dedupe_per_local_day` and cron-level `once_per_local_day` so morning briefings do not send twice on the same local day. Added `minio.share` tool to upload local artifacts and return temporary MinIO share links using existing backup MinIO credentials.",
|
||||
"summary": "Added first-class automation presets and scheduling upgrades: `automation.daily_briefing` now auto-registers an opinionated cron job for morning briefings, and backup scheduling now supports cron expressions via `backup.schedule` plus optional `backup.run_on_start` while preserving interval fallback. Added `BackupScheduler` with `backup.notify` channel alerts, configurable `backup.failure_threshold`, and recovery notifications (`backup.notify_recovery`) so backup failures/recoveries proactively notify operators. Extended heartbeat monitoring with `process_memory`, `backup`, and `provider_errors` checks (with thresholds) so high RSS usage, backup failure streaks, and model-provider error spikes proactively trigger health alerts. Added timezone-safe daily briefing dedupe via `automation.daily_briefing.dedupe_per_local_day` and cron-level `once_per_local_day` so morning briefings do not send twice on the same local day. Added `minio.share` tool to upload local artifacts and return temporary MinIO share links using existing backup MinIO credentials. Added `system.sessionAnalytics` RPC with SQLite-backed aggregates (daily sessions/messages, average messages per session, top active sessions) for operator usage tracking.",
|
||||
"files_modified": [
|
||||
"src/config/schema.ts",
|
||||
"src/config/schema.test.ts",
|
||||
@@ -28,6 +28,13 @@
|
||||
"src/tools/builtin/index.ts",
|
||||
"src/tools/index.ts",
|
||||
"src/tools/policy.ts",
|
||||
"src/session/store.ts",
|
||||
"src/session/store.test.ts",
|
||||
"src/session/manager.ts",
|
||||
"src/session/index.ts",
|
||||
"src/gateway/handlers/system.ts",
|
||||
"src/gateway/handlers/handlers.test.ts",
|
||||
"src/gateway/server.ts",
|
||||
"src/daemon/channels.ts",
|
||||
"src/daemon/channels.test.ts",
|
||||
"src/daemon/index.ts",
|
||||
@@ -35,10 +42,11 @@
|
||||
"src/gateway/handlers/services.ts",
|
||||
"src/gateway/handlers/services.test.ts",
|
||||
"src/tools/builtin/cron.ts",
|
||||
"docs/api/PROTOCOL.md",
|
||||
"config/default.yaml",
|
||||
"README.md"
|
||||
],
|
||||
"test_status": "pnpm test:run src/automation/presets.test.ts src/automation/cron.test.ts src/automation/heartbeat.test.ts src/backup/scheduler.test.ts src/backup/status.test.ts src/tools/builtin/minio-share.test.ts src/tools/policy.test.ts src/config/schema.test.ts src/daemon/channels.test.ts src/gateway/handlers/services.test.ts + pnpm typecheck passing"
|
||||
"test_status": "pnpm test:run src/automation/presets.test.ts src/automation/cron.test.ts src/automation/heartbeat.test.ts src/backup/scheduler.test.ts src/backup/status.test.ts src/tools/builtin/minio-share.test.ts src/tools/policy.test.ts src/config/schema.test.ts src/daemon/channels.test.ts src/gateway/handlers/services.test.ts src/session/store.test.ts src/gateway/handlers/handlers.test.ts + pnpm typecheck passing"
|
||||
},
|
||||
"backup-session-summary-audit-trail": {
|
||||
"status": "completed",
|
||||
@@ -3324,7 +3332,7 @@
|
||||
}
|
||||
},
|
||||
"overall_progress": {
|
||||
"total_test_count": 1848,
|
||||
"total_test_count": 1851,
|
||||
"all_tests_passing": true,
|
||||
"p0_completion": "3/3 (100%)",
|
||||
"p1_completion": "4/4 (100%)",
|
||||
|
||||
@@ -327,6 +327,65 @@ describe('system.tokenUsage handler', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('system.sessionAnalytics handler', () => {
|
||||
it('returns empty analytics when callback is not provided', async () => {
|
||||
const handlers = createSystemHandlers({
|
||||
startTime: Date.now(),
|
||||
version: '0.1.0',
|
||||
getSessionCount: () => 0,
|
||||
getToolCount: () => 0,
|
||||
getConnectionCount: () => 0,
|
||||
});
|
||||
|
||||
const req: GatewayRequest = { id: 3, method: 'system.sessionAnalytics' };
|
||||
const result = await handlers['system.sessionAnalytics'](req) as GatewayResponse;
|
||||
|
||||
expect(result.id).toBe(3);
|
||||
const r = result.result as {
|
||||
daily: unknown[];
|
||||
topSessions: unknown[];
|
||||
averageMessagesPerSession: number;
|
||||
totalSessions: number;
|
||||
totalMessages: number;
|
||||
};
|
||||
expect(r.daily).toEqual([]);
|
||||
expect(r.topSessions).toEqual([]);
|
||||
expect(r.averageMessagesPerSession).toBe(0);
|
||||
expect(r.totalSessions).toBe(0);
|
||||
expect(r.totalMessages).toBe(0);
|
||||
});
|
||||
|
||||
it('returns analytics from callback', async () => {
|
||||
const getSessionAnalytics = vi.fn(() => ({
|
||||
daily: [{ day: '2026-02-16', sessions: 2, messages: 8 }],
|
||||
topSessions: [{ sessionId: 'telegram:1', messages: 5, lastActivity: 1708080000 }],
|
||||
averageMessagesPerSession: 4,
|
||||
totalSessions: 2,
|
||||
totalMessages: 8,
|
||||
}));
|
||||
|
||||
const handlers = createSystemHandlers({
|
||||
startTime: Date.now(),
|
||||
version: '0.1.0',
|
||||
getSessionCount: () => 2,
|
||||
getToolCount: () => 0,
|
||||
getConnectionCount: () => 1,
|
||||
getSessionAnalytics,
|
||||
});
|
||||
|
||||
const req: GatewayRequest = {
|
||||
id: 4,
|
||||
method: 'system.sessionAnalytics',
|
||||
params: { days: 7, topLimit: 5 },
|
||||
};
|
||||
const result = await handlers['system.sessionAnalytics'](req) as GatewayResponse;
|
||||
|
||||
expect(getSessionAnalytics).toHaveBeenCalledWith({ days: 7, topLimit: 5 });
|
||||
expect(getPath(result.result, 'totalSessions')).toBe(2);
|
||||
expect(getPath(result.result, 'daily')).toEqual([{ day: '2026-02-16', sessions: 2, messages: 8 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session handlers', () => {
|
||||
const mockHistory = [
|
||||
{ role: 'user' as const, content: 'hello' },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { makeResponse, makeError, ErrorCode } from '../protocol.js';
|
||||
import type { MetricsSnapshot, EventEntry, ActiveRequestInfo } from '../metrics.js';
|
||||
import type { ServiceInfo } from './services.js';
|
||||
import type { NodeLocation, NodeStatus, NodePushToken } from './node.js';
|
||||
import type { SessionAnalyticsSnapshot } from '../../session/index.js';
|
||||
|
||||
/** Per-session token usage report returned by system.tokenUsage. */
|
||||
export interface TokenUsageEntry {
|
||||
@@ -64,6 +65,8 @@ export interface SystemHandlerDeps {
|
||||
getTokenUsage?: () => TokenUsageEntry[];
|
||||
/** Optional callback to retrieve aggregated metrics snapshot. */
|
||||
getMetrics?: () => MetricsSnapshot;
|
||||
/** Optional callback to retrieve session analytics. */
|
||||
getSessionAnalytics?: (opts?: { days?: number; topLimit?: number }) => SessionAnalyticsSnapshot;
|
||||
/** Optional callback to retrieve recent events. */
|
||||
getEvents?: (opts?: { level?: string; limit?: number }) => EventEntry[];
|
||||
/** Optional callback to retrieve active requests. */
|
||||
@@ -206,6 +209,24 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
return makeResponse(request.id, deps.getMetrics());
|
||||
},
|
||||
|
||||
'system.sessionAnalytics': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
if (!deps.getSessionAnalytics) {
|
||||
return makeResponse(request.id, {
|
||||
daily: [],
|
||||
topSessions: [],
|
||||
averageMessagesPerSession: 0,
|
||||
totalSessions: 0,
|
||||
totalMessages: 0,
|
||||
});
|
||||
}
|
||||
const params = request.params as { days?: number; topLimit?: number } | undefined;
|
||||
const snapshot = deps.getSessionAnalytics({
|
||||
days: params?.days,
|
||||
topLimit: params?.topLimit,
|
||||
});
|
||||
return makeResponse(request.id, snapshot);
|
||||
},
|
||||
|
||||
'system.events': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
if (!deps.getEvents) {
|
||||
return makeResponse(request.id, { events: [] });
|
||||
|
||||
@@ -190,6 +190,7 @@ export class GatewayServer {
|
||||
getSessionCount: () => this.sessionBridge.listSessions().length,
|
||||
getToolCount: () => this.config.toolRegistry.list().length,
|
||||
getConnectionCount: () => this.sessionBridge.connectionCount,
|
||||
getSessionAnalytics: ({ days, topLimit } = {}) => this.config.sessionManager.getSessionAnalytics({ days, topLimit }),
|
||||
restart: this.config.restart,
|
||||
getChannels: channelRegistry
|
||||
? () => channelRegistry.list().map(a => ({ name: a.name, status: a.status }))
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user