diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 8a17e18..0f1100e 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -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 { diff --git a/docs/plans/state.json b/docs/plans/state.json index 1d6883f..04d4080 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -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%)", diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 756252e..300dc97 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -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' }, diff --git a/src/gateway/handlers/system.ts b/src/gateway/handlers/system.ts index 5b476be..b635370 100644 --- a/src/gateway/handlers/system.ts +++ b/src/gateway/handlers/system.ts @@ -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 => { + 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 => { if (!deps.getEvents) { return makeResponse(request.id, { events: [] }); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 8afb8e4..56f273a 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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 })) diff --git a/src/session/index.ts b/src/session/index.ts index 3ba6ea4..5e34edd 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -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'; diff --git a/src/session/manager.ts b/src/session/manager.ts index 090d655..cbb7727 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -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, + }); + } } diff --git a/src/session/store.test.ts b/src/session/store.test.ts index b8e4216..44c247d 100644 --- a/src/session/store.test.ts +++ b/src/session/store.test.ts @@ -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'); diff --git a/src/session/store.ts b/src/session/store.ts index c16a3fb..47eeeca 100644 --- a/src/session/store.ts +++ b/src/session/store.ts @@ -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, + }; + } }