From 9f984908ab2f361901f61dfd8f122a6641144242 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai <157192462+saurabhhhcodes@users.noreply.github.com> Date: Thu, 28 May 2026 06:32:31 +0530 Subject: [PATCH] feat: add task analytics storage --- src/index.ts | 12 ++ src/router/taskAnalytics.ts | 205 +++++++++++++++++++++++++++++++++++ tests/task-analytics.test.js | 88 +++++++++++++++ 3 files changed, 305 insertions(+) create mode 100644 src/router/taskAnalytics.ts create mode 100644 tests/task-analytics.test.js diff --git a/src/index.ts b/src/index.ts index c769e99..aab41ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -252,6 +252,18 @@ export type { ApiKeyConfig } from "./router/types"; +export { + TaskAnalytics, + taskAnalytics +} from "./router/taskAnalytics"; + +export type { + TaskAnalyticsEvent, + TaskAnalyticsOptions, + TaskAnalyticsRecord, + TaskAnalyticsSummary +} from "./router/taskAnalytics"; + /** * Model configuration for bulk registration */ diff --git a/src/router/taskAnalytics.ts b/src/router/taskAnalytics.ts new file mode 100644 index 0000000..1629b73 --- /dev/null +++ b/src/router/taskAnalytics.ts @@ -0,0 +1,205 @@ +export interface TaskAnalyticsEvent { + id: string; + taskType: string; + confidence?: number; + model?: string; + provider?: string; + latencyMs?: number; + success: boolean; + timestamp: number; + metadata?: Record; +} + +export interface TaskAnalyticsRecord { + taskType: string; + confidence?: number; + model?: string; + provider?: string; + latencyMs?: number; + success?: boolean; + timestamp?: number | Date; + metadata?: Record; +} + +export interface TaskAnalyticsSummary { + totalClassifications: number; + successCount: number; + failureCount: number; + averageConfidence: number; + averageLatencyMs: number; + taskCounts: Record; + modelUsage: Record; + providerUsage: Record; + recentEvents: TaskAnalyticsEvent[]; +} + +export interface TaskAnalyticsOptions { + maxEvents?: number; +} + +const DEFAULT_MAX_EVENTS = 1000; + +export class TaskAnalytics { + private readonly maxEvents: number; + private events: TaskAnalyticsEvent[] = []; + private nextId = 1; + + constructor(options: TaskAnalyticsOptions = {}) { + const maxEvents = options.maxEvents ?? DEFAULT_MAX_EVENTS; + if (!Number.isInteger(maxEvents) || maxEvents <= 0) { + throw new Error("TaskAnalytics: maxEvents must be a positive integer"); + } + this.maxEvents = maxEvents; + } + + public recordClassification(record: TaskAnalyticsRecord): TaskAnalyticsEvent { + const event = normalizeRecord(record, this.nextId++); + this.events.push(event); + + if (this.events.length > this.maxEvents) { + this.events.splice(0, this.events.length - this.maxEvents); + } + + return { ...event, metadata: cloneMetadata(event.metadata) }; + } + + public getAnalytics(limit = 10): TaskAnalyticsSummary { + if (!Number.isInteger(limit) || limit < 0) { + throw new Error("TaskAnalytics: recent event limit must be a non-negative integer"); + } + + const taskCounts: Record = {}; + const modelUsage: Record = {}; + const providerUsage: Record = {}; + let confidenceTotal = 0; + let confidenceCount = 0; + let latencyTotal = 0; + let latencyCount = 0; + let successCount = 0; + + for (const event of this.events) { + taskCounts[event.taskType] = (taskCounts[event.taskType] || 0) + 1; + + if (event.model) { + modelUsage[event.model] = (modelUsage[event.model] || 0) + 1; + } + if (event.provider) { + providerUsage[event.provider] = (providerUsage[event.provider] || 0) + 1; + } + if (event.confidence !== undefined) { + confidenceTotal += event.confidence; + confidenceCount += 1; + } + if (event.latencyMs !== undefined) { + latencyTotal += event.latencyMs; + latencyCount += 1; + } + if (event.success) { + successCount += 1; + } + } + + return { + totalClassifications: this.events.length, + successCount, + failureCount: this.events.length - successCount, + averageConfidence: roundMetric(confidenceCount ? confidenceTotal / confidenceCount : 0), + averageLatencyMs: roundMetric(latencyCount ? latencyTotal / latencyCount : 0), + taskCounts, + modelUsage, + providerUsage, + recentEvents: this.events + .slice(Math.max(this.events.length - limit, 0)) + .map(event => ({ ...event, metadata: cloneMetadata(event.metadata) })) + }; + } + + public reset(): void { + this.events = []; + this.nextId = 1; + } +} + +export const taskAnalytics = new TaskAnalytics(); + +function normalizeRecord(record: TaskAnalyticsRecord, sequence: number): TaskAnalyticsEvent { + if (!record || typeof record !== "object") { + throw new Error("TaskAnalytics: record must be an object"); + } + + const taskType = normalizeNonEmptyString(record.taskType, "taskType"); + const confidence = normalizeOptionalNumber(record.confidence, "confidence", 0, 1); + const latencyMs = normalizeOptionalNumber(record.latencyMs, "latencyMs", 0); + const model = normalizeOptionalString(record.model); + const provider = normalizeOptionalString(record.provider); + const timestamp = normalizeTimestamp(record.timestamp); + + return { + id: `task-${timestamp}-${sequence}`, + taskType, + confidence, + model, + provider, + latencyMs, + success: record.success ?? true, + timestamp, + metadata: cloneMetadata(record.metadata) + }; +} + +function normalizeNonEmptyString(value: unknown, fieldName: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`TaskAnalytics: ${fieldName} must be a non-empty string`); + } + return value.trim(); +} + +function normalizeOptionalString(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "string" || value.trim() === "") { + return undefined; + } + return value.trim(); +} + +function normalizeOptionalNumber( + value: unknown, + fieldName: string, + min: number, + max?: number +): number | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "number" || !Number.isFinite(value) || value < min) { + throw new Error(`TaskAnalytics: ${fieldName} must be a finite number >= ${min}`); + } + if (max !== undefined && value > max) { + throw new Error(`TaskAnalytics: ${fieldName} must be <= ${max}`); + } + return value; +} + +function normalizeTimestamp(value: number | Date | undefined): number { + if (value === undefined) { + return Date.now(); + } + const timestamp = value instanceof Date ? value.getTime() : value; + if (!Number.isFinite(timestamp) || timestamp < 0) { + throw new Error("TaskAnalytics: timestamp must be a valid non-negative time"); + } + return timestamp; +} + +function cloneMetadata(metadata: Record | undefined): Record | undefined { + if (!metadata || typeof metadata !== "object") { + return undefined; + } + return { ...metadata }; +} + +function roundMetric(value: number): number { + return Number(value.toFixed(4)); +} diff --git a/tests/task-analytics.test.js b/tests/task-analytics.test.js new file mode 100644 index 0000000..7b8ac22 --- /dev/null +++ b/tests/task-analytics.test.js @@ -0,0 +1,88 @@ +const assert = require("assert"); +const { TaskAnalytics, taskAnalytics } = require("../dist/index.js"); + +function test(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (error) { + console.error(`not ok - ${name}`); + throw error; + } +} + +test("records classification events and summarizes counts", () => { + const analytics = new TaskAnalytics(); + + analytics.recordClassification({ + taskType: "code", + confidence: 0.9, + model: "gpt-5", + provider: "openai", + latencyMs: 120, + timestamp: 1000 + }); + analytics.recordClassification({ + taskType: "summary", + confidence: 0.7, + model: "gpt-5-mini", + provider: "openai", + latencyMs: 80, + success: false, + timestamp: 2000 + }); + + const summary = analytics.getAnalytics(); + + assert.strictEqual(summary.totalClassifications, 2); + assert.strictEqual(summary.successCount, 1); + assert.strictEqual(summary.failureCount, 1); + assert.deepStrictEqual(summary.taskCounts, { code: 1, summary: 1 }); + assert.strictEqual(summary.modelUsage["gpt-5"], 1); + assert.strictEqual(summary.providerUsage.openai, 2); + assert.strictEqual(summary.averageConfidence, 0.8); + assert.strictEqual(summary.averageLatencyMs, 100); +}); + +test("bounds retained events while preserving aggregate over retained window", () => { + const analytics = new TaskAnalytics({ maxEvents: 2 }); + + analytics.recordClassification({ taskType: "chat", timestamp: 1 }); + analytics.recordClassification({ taskType: "code", timestamp: 2 }); + analytics.recordClassification({ taskType: "code", timestamp: 3 }); + + const summary = analytics.getAnalytics(5); + + assert.strictEqual(summary.totalClassifications, 2); + assert.deepStrictEqual(summary.taskCounts, { code: 2 }); + assert.deepStrictEqual(summary.recentEvents.map(event => event.taskType), ["code", "code"]); +}); + +test("limits recent event output", () => { + const analytics = new TaskAnalytics(); + + analytics.recordClassification({ taskType: "chat", timestamp: 1 }); + analytics.recordClassification({ taskType: "code", timestamp: 2 }); + analytics.recordClassification({ taskType: "math", timestamp: 3 }); + + const summary = analytics.getAnalytics(2); + + assert.deepStrictEqual(summary.recentEvents.map(event => event.taskType), ["code", "math"]); +}); + +test("validates unsafe record values", () => { + const analytics = new TaskAnalytics(); + + assert.throws(() => analytics.recordClassification({ taskType: "" }), /taskType/); + assert.throws(() => analytics.recordClassification({ taskType: "code", confidence: 1.5 }), /confidence/); + assert.throws(() => analytics.recordClassification({ taskType: "code", latencyMs: -1 }), /latencyMs/); + assert.throws(() => new TaskAnalytics({ maxEvents: 0 }), /maxEvents/); +}); + +test("resets default analytics singleton", () => { + taskAnalytics.reset(); + taskAnalytics.recordClassification({ taskType: "chat", timestamp: 1 }); + assert.strictEqual(taskAnalytics.getAnalytics().totalClassifications, 1); + taskAnalytics.reset(); + assert.strictEqual(taskAnalytics.getAnalytics().totalClassifications, 0); +});